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 885d58e539 Cleanup the metadata perspective, fixes  #7319 (#7320)
885d58e539 is described below

commit 885d58e5390397856f7bf468272ecd48b03d1e77
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jun 19 16:07:53 2026 +0200

    Cleanup the metadata perspective, fixes  #7319 (#7320)
---
 .../org/apache/hop/core/database/DatabaseMeta.java |    2 +
 .../core/variables/resolver/VariableResolver.java  |    2 +
 .../org/apache/hop/metadata/api/HopMetadata.java   |    9 +
 .../hop/metadata/api/HopMetadataCategory.java      |   57 +
 docs/hop-user-manual/modules/ROOT/nav.adoc         |    4 +-
 .../pages/metadata-types/azure-authentication.adoc |    4 +-
 .../google-storage-authentication.adoc             |    2 +-
 .../modules/ROOT/pages/metadata-types/index.adoc   |    4 +-
 .../hop/datastream/metadata/DataStreamMeta.java    |    2 +
 .../hop/execution/ExecutionInfoLocation.java       |    2 +
 .../execution/profiling/ExecutionDataProfile.java  |    2 +
 .../org/apache/hop/partition/PartitionSchema.java  |    2 +
 .../pipeline/config/PipelineRunConfiguration.java  |    2 +
 .../java/org/apache/hop/server/HopServerMeta.java  |    2 +
 .../workflow/config/WorkflowRunConfiguration.java  |    2 +
 .../org/apache/hop/www/service/WebService.java     |    2 +
 .../apache/hop/beam/metadata/FileDefinition.java   |    2 +
 .../org/apache/hop/www/async/AsyncWebService.java  |    2 +
 .../hop/mail/metadata/MailServerConnection.java    |    2 +
 .../hop/reflection/pipeline/meta/PipelineLog.java  |    2 +
 .../hop/reflection/probe/meta/PipelineProbe.java   |    2 +
 .../hop/reflection/workflow/meta/WorkflowLog.java  |    2 +
 .../apache/hop/metadata/rest/RestConnection.java   |    2 +
 .../staticschema/metadata/SchemaDefinition.java    |    2 +
 .../main/java/org/apache/hop/testing/DataSet.java  |    2 +
 .../org/apache/hop/testing/PipelineUnitTest.java   |    2 +
 .../org/apache/hop/vfs/s3/metadata/S3Meta.java     |    2 +
 .../vfs/azure/metadatatype/AzureMetadataType.java  |    2 +
 .../messages/messages_en_US.properties             |    3 +-
 .../cassandra/metadata/CassandraConnection.java    |    2 +
 .../gs/metadatatype/GoogleStorageMetadataType.java |    2 +
 .../messages/messages_en_US.properties             |    3 +-
 .../apache/hop/vfs/minio/metadata/MinioMeta.java   |    2 +
 .../hop/mongo/metadata/MongoDbConnection.java      |    2 +
 .../org/apache/hop/neo4j/model/GraphModel.java     |    2 +
 .../org/apache/hop/neo4j/shared/NeoConnection.java |    2 +
 .../metadata/salesforce/SalesforceConnection.java  |    2 +
 .../hop/vfs/webdav/metadata/WebDavConnection.java  |    2 +
 .../org/apache/hop/splunk/SplunkConnection.java    |    2 +
 .../perspective/metadata/MetadataCategories.java   |  142 +++
 .../perspective/metadata/MetadataOverview.java     |  190 +++
 .../perspective/metadata/MetadataPerspective.java  | 1294 ++++++++++++++++----
 .../metadata/messages/messages_en_US.properties    |   26 +-
 .../metadata/MetadataCategoriesTest.java           |   86 ++
 44 files changed, 1625 insertions(+), 261 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java 
b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
index 6c93b73fc9..12e65b8e6e 100644
--- a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
@@ -51,6 +51,7 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -65,6 +66,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
     name = "i18n::DatabaseMeta.name",
     description = "i18n::DatabaseMeta.description",
     image = "ui/images/database.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/rdbms-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.RDBMS_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
 
b/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
index ca40b0ef15..c8749eb898 100644
--- 
a/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
+++ 
b/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
@@ -22,6 +22,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.IHopMetadata;
 
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::VariableResolver.name",
     description = "i18n::VariableResolver.Description",
     image = "ui/images/variable.svg",
+    category = HopMetadataCategory.VARIABLES,
     documentationUrl = "/metadata-types/variable-resolver/")
 @Getter
 @Setter
diff --git a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java 
b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
index 9945b58130..3cf6f87de4 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
@@ -43,6 +43,15 @@ public @interface HopMetadata {
 
   String image() default "ui/images/folder.svg";
 
+  /**
+   * The category this metadata type belongs to. Used purely for presentation 
(e.g. grouping types
+   * under a common heading in the metadata perspective). Use one of the 
stable ids defined in
+   * {@link HopMetadataCategory}. An empty value (the default) groups the type 
under "Other".
+   *
+   * @return the category id for this metadata type
+   */
+  String category() default "";
+
   String documentationUrl() default "";
 
   /**
diff --git 
a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java 
b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java
new file mode 100644
index 0000000000..9d575014ba
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java
@@ -0,0 +1,57 @@
+/*
+ * 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.metadata.api;
+
+/**
+ * Stable category ids referenced by {@link HopMetadata#category()}. These ids 
only group metadata
+ * types for presentation; the human readable label, display order and icon 
are resolved in the UI
+ * layer so that this {@code core} class carries no presentation concerns.
+ */
+public final class HopMetadataCategory {
+
+  /** Database, NoSQL, graph and other data-source connections. */
+  public static final String CONNECTIONS = "connections";
+
+  /** Virtual File System / cloud storage connections (S3, Azure, GCS, MinIO, 
WebDAV, ...). */
+  public static final String FILE_STORAGE = "file-storage";
+
+  /** Pipeline and workflow run configurations. */
+  public static final String RUN_CONFIG = "run-config";
+
+  /** Hop servers and published web services. */
+  public static final String SERVERS = "servers";
+
+  /** Execution information locations and data profiling. */
+  public static final String EXECUTION = "execution";
+
+  /** Pipeline/workflow logging, probes and log readers. */
+  public static final String LOGGING = "logging";
+
+  /** Unit tests and data sets. */
+  public static final String TESTING = "testing";
+
+  /** Schema, partition and file definitions describing data shape. */
+  public static final String DATA_DEFINITION = "data-definition";
+
+  /** Variable resolvers. */
+  public static final String VARIABLES = "variables";
+
+  private HopMetadataCategory() {
+    // Constants holder, do not instantiate.
+  }
+}
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc 
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 16fd712938..e8d04fc058 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -437,13 +437,13 @@ under the License.
 ** xref:logging/logging-reflection.adoc[Logging reflection]
 * xref:metadata-types/index.adoc[Metadata Types]
 ** xref:metadata-types/async-web-service.adoc[]
-** xref:metadata-types/azure-authentication.adoc[Azure Authentication]
+** xref:metadata-types/azure-authentication.adoc[Azure Connection]
 ** xref:metadata-types/beam-file-definition.adoc[Beam File Definition]
 ** xref:metadata-types/cassandra/cassandra-connection.adoc[Cassandra 
Connection]
 ** xref:metadata-types/data-set.adoc[Data Set]
 ** xref:metadata-types/execution-data-profile.adoc[]
 ** xref:metadata-types/execution-information-location.adoc[]
-** xref:metadata-types/google-storage-authentication.adoc[Google Storage 
Authentication]
+** xref:metadata-types/google-storage-authentication.adoc[Google Storage 
Connection]
 ** xref:metadata-types/hop-server.adoc[Hop Server]
 ** xref:metadata-types/mail-server-connection.adoc[Mail Server Connection]
 ** xref:metadata-types/minio-connection.adoc[Minio (S3) Connection]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
index aef779419c..f5fc5340e6 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
@@ -18,11 +18,11 @@ under the License.
 :page-pagination:
 :description: This is used to register multiple Azure blob storage accounts
 
-= Azure Authentication
+= Azure Connection
 
 == Description
 This metadata type can be used to add a new Azure Storage file system to your 
project. This allows you to connect to multiple storage accounts at the same 
time.
-Using an Azure authentication to connect to a blob store requires you to 
specify the container name in the path. For example, if your Azure 
authentication is named az, you would need to specify the container as 
**az:/containerName**.
+Using an Azure connection to connect to a blob store requires you to specify 
the container name in the path. For example, if your Azure connection is named 
az, you would need to specify the container as **az:/containerName**.
 
 == Options
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
index ddab5f7cbe..8ad6b64478 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
@@ -18,7 +18,7 @@ under the License.
 :page-pagination:
 :description: This is used to register multiple Google cloud storage accounts
 
-= Google Storage Authentication
+= Google Storage Connection
 
 == Description
 This metadata type can be used to add a new Google Cloud Storage file system 
to your project. This allows you to connect to multiple storage accounts at the 
same time.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
index 2252c38e26..ca4b5ecd1a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
@@ -36,14 +36,14 @@ By default, Hop contains the following metadata types:
 
 
 * xref:hop-server/async-web-service.adoc[Asynchronous Web Service]: Execute 
and query a workflow asynchronously through a web service.
-* xref:metadata-types/azure-authentication.adoc[Azure Blob Storage 
Authentication]: A Azure Blob Storage connection type.
+* xref:metadata-types/azure-authentication.adoc[Azure Connection]: An Azure 
Blob Storage connection type.
 * xref:metadata-types/beam-file-definition.adoc[Beam File Definition]: 
Describes a file layout in a Beam Pipeline
 * xref:metadata-types/cassandra/cassandra-connection.adoc[Cassandra 
Connection]: Describes a connection to a Cassandra cluster
 * xref:metadata-types/data-set.adoc[Data Set]: This defines a data set, a 
static pre-defined collection of rows
 xref:metadata-types/data-stream/data-stream.adoc[Data Stream]: A Data Stream 
can be used for Inter Process Communication (IPC) or fast data serialization.
 * xref:metadata-types/execution-data-profile.adoc[Execution Data Profile]: 
Collects and profiles data as it flows through a pipeline using configurable 
samplers for insight into value ranges, nulls, and row samples.
 * xref:metadata-types/execution-information-location.adoc[Execution 
Information Location]: Defines where and how Apache Hop stores execution 
metadata, supporting local files, remote servers, Neo4j, or Elastic for later 
inspection and analysis.
-* xref:metadata-types/google-storage-authentication.adoc[Google Storage 
Authentication]: A Google Cloud Storage connection type.
+* xref:metadata-types/google-storage-authentication.adoc[Google Storage 
Connection]: A Google Cloud Storage connection type.
 * xref:metadata-types/hop-server.adoc[Hop Server]: Defines a Hop Server
 * xref:metadata-types/mongodb-connection.adoc[MongoDB Connection]: Describes a 
MongoDB connection
 * xref:metadata-types/mail-server-connection.adoc[Mail Server Connection]: 
Describes a mail server connection
diff --git 
a/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java 
b/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
index 53b2a8b52f..67ad1c9abc 100644
--- 
a/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
+++ 
b/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
@@ -23,6 +23,7 @@ import lombok.Setter;
 import org.apache.hop.datastream.plugin.IDataStream;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -32,6 +33,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::DataStream.name",
     description = "i18n::DataStream.description",
     image = "ui/images/arrow-stream.svg",
+    category = HopMetadataCategory.DATA_DEFINITION,
     documentationUrl = "/metadata-types/data-stream.html",
     hopMetadataPropertyType = HopMetadataPropertyType.DATA_STREAM,
     supportsGlobalReplace = true)
diff --git 
a/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java 
b/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
index fc1b44db0c..0c66151fa3 100644
--- a/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
+++ b/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
@@ -21,6 +21,7 @@ package org.apache.hop.execution;
 import org.apache.hop.execution.profiling.ExecutionDataProfile;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::ExecutionInfoLocation.name",
     description = "i18n::ExecutionInfoLocation.description",
     image = "ui/images/location.svg",
+    category = HopMetadataCategory.EXECUTION,
     documentationUrl = "/metadata-types/execution-information-location.html",
     hopMetadataPropertyType = HopMetadataPropertyType.EXEC_INFO_LOCATION,
     supportsGlobalReplace = true)
diff --git 
a/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
 
b/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
index 90e1a00f03..9d36867d5a 100644
--- 
a/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
+++ 
b/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
@@ -23,6 +23,7 @@ import java.util.List;
 import org.apache.hop.execution.sampler.IExecutionDataSampler;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::ExecutionDataProfile.name",
     description = "i18n::ExecutionDataProfile.description",
     image = "ui/images/analyzer.svg",
+    category = HopMetadataCategory.EXECUTION,
     documentationUrl = "/metadata-types/execution-data-profile.html",
     hopMetadataPropertyType = HopMetadataPropertyType.EXEC_INFO_DATA_PROFILE,
     supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java 
b/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
index 5aba63b386..0ae21954e4 100644
--- a/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
+++ b/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
@@ -23,6 +23,7 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::PartitionSchema.name",
     description = "i18n::PartitionSchema.description",
     image = "ui/images/partition_schema.svg",
+    category = HopMetadataCategory.DATA_DEFINITION,
     documentationUrl = "/metadata-types/partition-schema.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PARTITION_SCHEMA)
 public class PartitionSchema extends HopMetadataBase implements Cloneable, 
IHopMetadata {
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
 
b/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
index 799b430543..1099df1894 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.execution.profiling.ExecutionDataProfile;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
     name = "i18n::PipelineRunConfiguration.name",
     description = "i18n::PipelineRunConfiguration.description",
     image = "ui/images/pipeline_run_config.svg",
+    category = HopMetadataCategory.RUN_CONFIG,
     documentationUrl = "/metadata-types/pipeline-run-config.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_RUN_CONFIG,
     supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/server/HopServerMeta.java 
b/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
index ee5935210e..1b3052c003 100644
--- a/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
+++ b/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
@@ -74,6 +74,7 @@ import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -104,6 +105,7 @@ import org.w3c.dom.Node;
     name = "i18n::HopServer.name",
     description = "i18n::HopServer.description",
     image = "ui/images/server.svg",
+    category = HopMetadataCategory.SERVERS,
     documentationUrl = "/metadata-types/hop-server.html",
     hopMetadataPropertyType = HopMetadataPropertyType.SERVER_DEFINITION,
     supportsGlobalReplace = true)
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
 
b/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
index 23cce5bd3c..c6a45bcd85 100644
--- 
a/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
+++ 
b/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
@@ -20,6 +20,7 @@ package org.apache.hop.workflow.config;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
     name = "i18n::WorkflowRunConfiguration.name",
     description = "i18n::WorkflowRunConfiguration.description",
     image = "ui/images/workflow_run_config.svg",
+    category = HopMetadataCategory.RUN_CONFIG,
     documentationUrl = "/metadata-types/workflow-run-config.html",
     hopMetadataPropertyType = HopMetadataPropertyType.WORKFLOW_RUN_CONFIG,
     supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/www/service/WebService.java 
b/engine/src/main/java/org/apache/hop/www/service/WebService.java
index 9d3171146b..be91d079b4 100644
--- a/engine/src/main/java/org/apache/hop/www/service/WebService.java
+++ b/engine/src/main/java/org/apache/hop/www/service/WebService.java
@@ -22,6 +22,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -31,6 +32,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::WebService.name",
     description = "i18n::WebService.description",
     image = "ui/images/webservice.svg",
+    category = HopMetadataCategory.SERVERS,
     documentationUrl = "/metadata-types/web-service.html",
     hopMetadataPropertyType = HopMetadataPropertyType.SERVER_WEB_SERVICE)
 @Getter
diff --git 
a/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
 
b/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
index 11f096b29c..db7cb4e9cd 100644
--- 
a/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
+++ 
b/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
@@ -28,6 +28,7 @@ import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -37,6 +38,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::BeamFileDefinition.name",
     description = "i18n::BeamFileDefinition.description",
     image = "ui/images/folder.svg",
+    category = HopMetadataCategory.DATA_DEFINITION,
     documentationUrl = "/metadata-types/beam-file-definition.html",
     hopMetadataPropertyType = HopMetadataPropertyType.BEAM_FILE_DEFINITION)
 public class FileDefinition extends HopMetadataBase implements Serializable, 
IHopMetadata {
diff --git 
a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
 
b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
index 0cdd2410d6..90ac371afb 100644
--- 
a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
+++ 
b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -35,6 +36,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::AsyncWebService.name",
     description = "i18n::AsyncWebService.description",
     image = "ui/images/server.svg",
+    category = HopMetadataCategory.SERVERS,
     documentationUrl = "/metadata-types/async-web-service.html",
     hopMetadataPropertyType = HopMetadataPropertyType.SERVER_WEB_SERVICE_ASYNC)
 @Getter
diff --git 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
index fdda98e231..bd26967841 100644
--- 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
+++ 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
@@ -66,6 +66,7 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.mail.common.MailConst;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -79,6 +80,7 @@ import org.eclipse.angus.mail.pop3.POP3SSLStore;
     name = "i18n::MailServerConnection.name",
     description = "i18n::MailServerConnection.description",
     image = "mail.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/mail-server-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.MAIL_SERVER_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
index 42e81f361f..98f743f950 100644
--- 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
+++ 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
@@ -25,6 +25,7 @@ import lombok.Setter;
 import org.apache.hop.core.logging.LogLevel;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::PipelineLog.name",
     description = "i18n::PipelineLog.description",
     image = "pipeline-log.svg",
+    category = HopMetadataCategory.LOGGING,
     documentationUrl = "/metadata-types/pipeline-log.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_LOG)
 @Getter
diff --git 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
index 28ef66b13b..8cf078d0be 100644
--- 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
+++ 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -31,6 +32,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::PipelineProbe.name",
     description = "i18n::PipelineProbe.desciption",
     image = "probe.svg",
+    category = HopMetadataCategory.LOGGING,
     documentationUrl = "/metadata-types/pipeline-probe.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_PROBE)
 public class PipelineProbe extends HopMetadataBase implements IHopMetadata {
diff --git 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
index 69d3ccd0f9..14c348a793 100644
--- 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
+++ 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
@@ -25,6 +25,7 @@ import lombok.Setter;
 import org.apache.hop.core.logging.LogLevel;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::WorkflowLog.name",
     description = "i18n::WorkflowLog.description",
     image = "workflow-log.svg",
+    category = HopMetadataCategory.LOGGING,
     documentationUrl = "/metadata-types/workflow-log.html",
     hopMetadataPropertyType = HopMetadataPropertyType.WORKFLOW_LOG)
 @Getter
diff --git 
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
 
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
index 53faf5cd87..f79abe626e 100644
--- 
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
+++ 
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
@@ -46,6 +46,7 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -59,6 +60,7 @@ import 
org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
     name = "i18n::RestConnection.name",
     description = "i18n::RestConnection.description",
     image = "rest.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/rest-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.REST_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
 
b/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
index a5b2e72d10..6fcc629eaa 100644
--- 
a/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
+++ 
b/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
@@ -28,6 +28,7 @@ import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -37,6 +38,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::SchemaDefinition.Name",
     description = "i18n::SchemaDefinition.Description",
     image = "ui/images/folder.svg",
+    category = HopMetadataCategory.DATA_DEFINITION,
     documentationUrl = "/metadata-types/static-schema-definition.html",
     hopMetadataPropertyType = HopMetadataPropertyType.STATIC_SCHEMA_DEFINITION)
 public class SchemaDefinition extends HopMetadataBase implements Serializable, 
IHopMetadata {
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
index a2f5984086..d6032005a3 100644
--- a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
+++ b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.row.value.ValueMetaFactory;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -40,6 +41,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::DataSet.name",
     description = "i18n::DataSet.description",
     image = "dataset.svg",
+    category = HopMetadataCategory.TESTING,
     documentationUrl = "/metadata-types/data-set.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_DATA_SET,
     supportsGlobalReplace = true)
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
index b4634ca3e5..3144d8e23d 100644
--- 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -44,6 +45,7 @@ import org.apache.hop.testing.util.DataSetConst;
     name = "i18n::PipelineUnitTest.name",
     description = "i18n::PipelineUnitTest.description",
     image = "Test_tube_icon.svg",
+    category = HopMetadataCategory.TESTING,
     documentationUrl = "/metadata-types/pipeline-unit-test.html",
     hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_UNIT_TEST,
     supportsGlobalReplace = true)
diff --git 
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java 
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
index 2b3fe399b0..3f63f15b0c 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
@@ -22,6 +22,7 @@ import lombok.Setter;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::S3Meta.name",
     description = "i18n::S3Meta.description",
     image = "s3.svg",
+    category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/s3-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_S3_CONNECTION)
 public class S3Meta extends HopMetadataBase implements Serializable, 
IHopMetadata {
diff --git 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
index 5ad39f001d..535c8fbefb 100644
--- 
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
+++ 
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
@@ -21,6 +21,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::AzureMetadataType.Name",
     description = "i18n::AzureMetadataType.Description",
     image = "ui/images/authentication.svg",
+    category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/azure-authentication.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_AZURE_CONNECTION)
 @Getter
diff --git 
a/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
 
b/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
index 44fc1bb5a5..dc56684b92 100644
--- 
a/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
+++ 
b/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
@@ -17,7 +17,8 @@
 #
 #
 
-AzureMetadataType.Name=Azure Authentication
+AzureMetadataType.Name=Azure Connection
+AzureMetadataType.Description=Named Azure Blob Storage connection for VFS
 AzureMetadataTypeEditor.Name.Label=Name
 AzureMetadataTypeEditor.Description.Label=Description
 AzureMetadataTypeEditor.StorageAccountName.Label=Storage Account Name
diff --git 
a/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
 
b/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
index 5313bb8ac0..1fc8a61aac 100644
--- 
a/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
+++ 
b/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
@@ -32,6 +32,7 @@ import org.apache.hop.databases.cassandra.spi.Keyspace;
 import org.apache.hop.databases.cassandra.util.CassandraUtils;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -42,6 +43,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::CassandraConnection.name",
     description = "i18n::CassandraConnection.description",
     image = "Cassandra_logo.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/cassandra/cassandra-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.CASSANDRA_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
index 61b5fd0e20..e60c2e048d 100644
--- 
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++ 
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
@@ -21,6 +21,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::GoogleStorageMetadataType.Name",
     description = "i18n::GoogleStorageMetadataType.Description",
     image = "ui/images/authentication.svg",
+    category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/google-storage-authentication.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION)
 @Getter
diff --git 
a/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
 
b/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
index ff08597ce6..eb4016d470 100644
--- 
a/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
+++ 
b/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
@@ -17,7 +17,8 @@
 #
 #
 
-GoogleStorageMetadataType.Name=Google Storage Authentication
+GoogleStorageMetadataType.Name=Google Storage Connection
+GoogleStorageMetadataType.Description=Named Google Cloud Storage connection 
for VFS
 GoogleStorageMetadataTypeEditor.Name.Label=Name
 GoogleStorageMetadataTypeEditor.Description.Label=Description
 GoogleStorageMetadataTypeEditor.StorageAccountName.Label=Storage Account Name
diff --git 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
index b74708b9f9..9be104c995 100644
--- 
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
+++ 
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
@@ -24,6 +24,7 @@ import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.GuiWidgetElement;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::MinioMeta.Name",
     description = "i18n::MinioMeta.Description",
     image = "minio.svg",
+    category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/minio-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_MINIO_CONNECTION)
 public class MinioMeta extends HopMetadataBase implements Serializable, 
IHopMetadata {
diff --git 
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
 
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
index a381ce82bb..9f6f256523 100644
--- 
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
+++ 
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -48,6 +49,7 @@ import org.apache.hop.mongo.wrapper.MongoClientWrapperFactory;
     name = "i18n::MongoDbConnection.name",
     description = "i18n::MongoDbConnection.description",
     image = "MongoDB_Leaf_FullColor_RGB.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/mongodb-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.MONGODB_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
index 6828293102..2c9fd6991f 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
@@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -40,6 +41,7 @@ import org.json.simple.parser.JSONParser;
     name = "i18n::GraphModel.name",
     description = "i18n::GraphModel.description",
     image = "neo4j_logo.svg",
+    category = HopMetadataCategory.DATA_DEFINITION,
     documentationUrl = "/metadata-types/neo4j/neo4j-graphmodel.html",
     hopMetadataPropertyType = HopMetadataPropertyType.GRAPH_MODEL)
 public class GraphModel extends HopMetadataBase implements IHopMetadata {
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
index 651a64c88c..fb55feec08 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
@@ -38,6 +38,7 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -57,6 +58,7 @@ import org.neo4j.driver.Value;
     name = "i18n::NeoConnection.name",
     description = "i18n::NeoConnection.description",
     image = "neo4j_logo.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/neo4j/neo4j-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.GRAPH_CONNECTION)
 public class NeoConnection extends HopMetadataBase implements IHopMetadata {
diff --git 
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
 
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
index 0fc3894c60..c41252bc2a 100644
--- 
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
+++ 
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
@@ -23,6 +23,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -32,6 +33,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::SalesforceConnection.name",
     description = "i18n::SalesforceConnection.description",
     image = "salesforce.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/salesforce-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.SALESFORCE_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
index 0357decc10..69b3461f6d 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
@@ -21,6 +21,7 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -38,6 +39,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::WebDavConnection.Name",
     description = "i18n::WebDavConnection.Description",
     image = "ui/images/authentication.svg",
+    category = HopMetadataCategory.FILE_STORAGE,
     documentationUrl = "/metadata-types/webdav-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.VFS_WEBDAV_CONNECTION)
 public class WebDavConnection extends HopMetadataBase implements Serializable, 
IHopMetadata {
diff --git 
a/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
 
b/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
index 9b1508dbbe..6a5de4fd06 100644
--- 
a/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
+++ 
b/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -35,6 +36,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
     name = "i18n::SplunkConnection.name",
     description = "i18n::SplunkConnection.description",
     image = "splunk.svg",
+    category = HopMetadataCategory.CONNECTIONS,
     documentationUrl = "/metadata-types/splunk-connection.html",
     hopMetadataPropertyType = HopMetadataPropertyType.SPLUNK_CONNECTION,
     supportsGlobalReplace = true)
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
new file mode 100644
index 0000000000..9cc258f091
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+import java.util.List;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataCategory;
+
+/**
+ * Presentation registry for metadata categories. The {@code core} layer only 
carries a stable
+ * category id on each {@code @HopMetadata} type (see {@link 
HopMetadataCategory}); the human
+ * readable label, the display order and the icon shown in the metadata 
perspective are resolved
+ * here so that no presentation concern leaks into {@code core}.
+ *
+ * <p>Categories declared below are shown in declaration order. An unknown 
(but non-empty) category
+ * id sorts after all known categories (alphabetically by id); the empty 
category id is the "Other"
+ * bucket and always sorts last.
+ */
+public final class MetadataCategories {
+
+  private static final Class<?> PKG = MetadataPerspective.class; // i18n
+
+  /** The empty category id: types without a category land in the "Other" 
bucket. */
+  public static final String OTHER = "";
+
+  private static final String OTHER_IMAGE = "ui/images/metadata.svg";
+
+  /** A known category: its id, the i18n key for its label and the icon shown 
in the tree. */
+  private record CategoryInfo(String id, String labelKey, String image) {}
+
+  /** Declaration order is the display order in the tree, the overview and the 
new-type menu. */
+  private static final List<CategoryInfo> ORDERED =
+      List.of(
+          new CategoryInfo(
+              HopMetadataCategory.CONNECTIONS,
+              "MetadataPerspective.Category.Connections",
+              "ui/images/database.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.FILE_STORAGE,
+              "MetadataPerspective.Category.FileStorage",
+              "ui/images/location.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.RUN_CONFIG,
+              "MetadataPerspective.Category.RunConfig",
+              "ui/images/gear.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.SERVERS,
+              "MetadataPerspective.Category.Servers",
+              "ui/images/server.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.EXECUTION,
+              "MetadataPerspective.Category.Execution",
+              "ui/images/analyzer.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.LOGGING,
+              "MetadataPerspective.Category.Logging",
+              "ui/images/log.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.TESTING,
+              "MetadataPerspective.Category.Testing",
+              "ui/images/catalog.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.DATA_DEFINITION,
+              "MetadataPerspective.Category.DataDefinition",
+              "ui/images/partition_schema.svg"),
+          new CategoryInfo(
+              HopMetadataCategory.VARIABLES,
+              "MetadataPerspective.Category.Variables",
+              "ui/images/variable.svg"));
+
+  private MetadataCategories() {
+    // Utility class
+  }
+
+  /** Normalizes a (possibly null) category id to either a known/unknown id or 
{@link #OTHER}. */
+  public static String normalize(String categoryId) {
+    return Utils.isEmpty(categoryId) ? OTHER : categoryId;
+  }
+
+  private static CategoryInfo find(String categoryId) {
+    for (CategoryInfo info : ORDERED) {
+      if (info.id().equals(categoryId)) {
+        return info;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Sort key for a category id. Known categories keep their declaration 
order; unknown non-empty
+   * ids come next; the "Other" bucket is always last.
+   */
+  public static int orderOf(String categoryId) {
+    String id = normalize(categoryId);
+    if (OTHER.equals(id)) {
+      return Integer.MAX_VALUE;
+    }
+    for (int i = 0; i < ORDERED.size(); i++) {
+      if (ORDERED.get(i).id().equals(id)) {
+        return i;
+      }
+    }
+    // Unknown but non-empty category: after all known ones, before "Other".
+    return ORDERED.size();
+  }
+
+  /** The translated label for a category id, suitable for display as a 
tree/overview heading. */
+  public static String labelFor(String categoryId) {
+    String id = normalize(categoryId);
+    if (OTHER.equals(id)) {
+      return BaseMessages.getString(PKG, "MetadataPerspective.Category.Other");
+    }
+    CategoryInfo info = find(id);
+    if (info != null) {
+      return BaseMessages.getString(PKG, info.labelKey());
+    }
+    // Unknown category id from a third-party plugin: show the raw id rather 
than nothing.
+    return id;
+  }
+
+  /** The icon path for a category id, falling back to a generic metadata 
icon. */
+  public static String imageFor(String categoryId) {
+    CategoryInfo info = find(normalize(categoryId));
+    return info != null ? info.image() : OTHER_IMAGE;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
new file mode 100644
index 0000000000..9f6cc37602
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CLabel;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Label;
+
+/**
+ * Landing page shown in the metadata perspective whenever no editor tab is 
open. It presents the
+ * full catalog of metadata types (including types that have no items yet, 
which are hidden in the
+ * tree) grouped by category, each with its description, item count and a 
"New" button. It therefore
+ * doubles as the discovery surface for the perspective.
+ */
+public class MetadataOverview extends Composite {
+
+  private static final Class<?> PKG = MetadataPerspective.class; // i18n
+
+  /** Immutable view of a single metadata type, used to render one card. */
+  public record TypeCard(
+      String categoryId,
+      String key,
+      String name,
+      String description,
+      String image,
+      ClassLoader imageClassLoader,
+      int count) {}
+
+  private final MetadataPerspective perspective;
+  private final ScrolledComposite scrolledComposite;
+  private Composite content;
+
+  public MetadataOverview(Composite parent, MetadataPerspective perspective) {
+    super(parent, SWT.NONE);
+    this.perspective = perspective;
+    setLayout(new FillLayout());
+    PropsUi.setLook(this);
+
+    scrolledComposite = new ScrolledComposite(this, SWT.V_SCROLL | 
SWT.H_SCROLL);
+    scrolledComposite.setExpandHorizontal(true);
+    scrolledComposite.setExpandVertical(true);
+    PropsUi.setLook(scrolledComposite);
+  }
+
+  /** Rebuilds the overview from the given cards (grouped and ordered by 
category). */
+  public void setCards(List<TypeCard> cards) {
+    if (content != null && !content.isDisposed()) {
+      content.dispose();
+    }
+
+    content = new Composite(scrolledComposite, SWT.NONE);
+    GridLayout layout = new GridLayout(1, false);
+    layout.marginWidth = 2 * PropsUi.getFormMargin();
+    layout.marginHeight = 2 * PropsUi.getFormMargin();
+    layout.verticalSpacing = PropsUi.getMargin();
+    content.setLayout(layout);
+    PropsUi.setLook(content);
+
+    Label title = new Label(content, SWT.NONE);
+    title.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Overview.Title"));
+    title.setFont(GuiResource.getInstance().getFontLarge());
+    PropsUi.setLook(title);
+
+    Label subtitle = new Label(content, SWT.WRAP);
+    subtitle.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Overview.Subtitle"));
+    subtitle.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+    PropsUi.setLook(subtitle);
+
+    // Group the cards by category id and order the categories the same way 
the tree does.
+    Map<String, List<TypeCard>> cardsByCategory = new LinkedHashMap<>();
+    for (TypeCard card : cards) {
+      cardsByCategory.computeIfAbsent(card.categoryId(), k -> new 
ArrayList<>()).add(card);
+    }
+    List<String> categoryIds = new ArrayList<>(cardsByCategory.keySet());
+    categoryIds.sort(
+        Comparator.comparingInt(MetadataCategories::orderOf)
+            .thenComparing(MetadataCategories::labelFor));
+
+    for (String categoryId : categoryIds) {
+      // CLabel (not Label) so the icon and the category name are shown 
together; a plain SWT Label
+      // renders only the image when both an image and text are set.
+      CLabel categoryLabel = new CLabel(content, SWT.NONE);
+      categoryLabel.setText(MetadataCategories.labelFor(categoryId));
+      categoryLabel.setImage(
+          GuiResource.getInstance()
+              .getImage(
+                  MetadataCategories.imageFor(categoryId),
+                  getClass().getClassLoader(),
+                  ConstUi.SMALL_ICON_SIZE,
+                  ConstUi.SMALL_ICON_SIZE));
+      categoryLabel.setFont(GuiResource.getInstance().getFontMediumBold());
+      GridData categoryGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+      categoryGd.verticalIndent = PropsUi.getMargin();
+      categoryLabel.setLayoutData(categoryGd);
+      PropsUi.setLook(categoryLabel);
+
+      for (TypeCard card : cardsByCategory.get(categoryId)) {
+        createCard(content, card);
+      }
+    }
+
+    scrolledComposite.setContent(content);
+    scrolledComposite.setMinSize(content.computeSize(SWT.DEFAULT, 
SWT.DEFAULT));
+    content.layout();
+    scrolledComposite.layout();
+  }
+
+  private void createCard(Composite parent, TypeCard card) {
+    Composite row = new Composite(parent, SWT.NONE);
+    GridLayout rowLayout = new GridLayout(3, false);
+    rowLayout.marginWidth = PropsUi.getMargin();
+    rowLayout.marginHeight = PropsUi.getMargin();
+    row.setLayout(rowLayout);
+    GridData rowGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+    rowGd.horizontalIndent = ConstUi.MEDIUM_ICON_SIZE;
+    row.setLayoutData(rowGd);
+    PropsUi.setLook(row);
+
+    Label icon = new Label(row, SWT.NONE);
+    icon.setImage(
+        GuiResource.getInstance()
+            .getImage(
+                card.image(),
+                card.imageClassLoader(),
+                ConstUi.MEDIUM_ICON_SIZE,
+                ConstUi.MEDIUM_ICON_SIZE));
+    icon.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false));
+    PropsUi.setLook(icon);
+
+    Composite textBlock = new Composite(row, SWT.NONE);
+    GridLayout textLayout = new GridLayout(1, false);
+    textLayout.marginWidth = 0;
+    textLayout.marginHeight = 0;
+    textBlock.setLayout(textLayout);
+    textBlock.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+    PropsUi.setLook(textBlock);
+
+    Label name = new Label(textBlock, SWT.NONE);
+    name.setText(card.name() + "  (" + card.count() + ")");
+    name.setFont(GuiResource.getInstance().getFontBold());
+    name.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+    PropsUi.setLook(name);
+    // Clicking the name navigates to the type in the tree (when it is visible 
there).
+    name.addListener(SWT.MouseUp, e -> perspective.selectType(card.key()));
+
+    if (card.description() != null && !card.description().isEmpty()) {
+      Label description = new Label(textBlock, SWT.WRAP);
+      description.setText(card.description());
+      description.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, 
false));
+      PropsUi.setLook(description);
+    }
+
+    Button newButton = new Button(row, SWT.PUSH);
+    newButton.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Overview.New.Button"));
+    newButton.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false));
+    newButton.addListener(
+        SWT.Selection, e -> 
perspective.createNewMetadataFromOverview(card.key()));
+    PropsUi.setLook(newButton);
+  }
+}
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 e79132e93d..a1b452f6f1 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
@@ -20,8 +20,12 @@ package org.apache.hop.ui.hopgui.perspective.metadata;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import lombok.Getter;
 import org.apache.hop.core.Const;
@@ -37,6 +41,9 @@ import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.util.TranslateUtil;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditList;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.IAuditManager;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -59,6 +66,7 @@ import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.metadata.MetadataEditor;
 import org.apache.hop.ui.core.metadata.MetadataFileType;
@@ -90,6 +98,7 @@ import org.eclipse.swt.custom.CTabFolder2Adapter;
 import org.eclipse.swt.custom.CTabFolderEvent;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.custom.StackLayout;
 import org.eclipse.swt.custom.TreeEditor;
 import org.eclipse.swt.dnd.DND;
 import org.eclipse.swt.dnd.DragSource;
@@ -100,6 +109,7 @@ import org.eclipse.swt.dnd.DropTargetAdapter;
 import org.eclipse.swt.dnd.DropTargetEvent;
 import org.eclipse.swt.graphics.GC;
 import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
@@ -133,8 +143,22 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   public static final Class<?> PKG = MetadataPerspective.class; // i18n
   private static final String METADATA_PERSPECTIVE_TREE = "Metadata 
perspective tree";
 
+  /**
+   * Audit-trail key under which the project's (possibly empty) virtual 
folders are stored so they
+   * survive refresh and restart. Each entry is {@code typeKey + 
FOLDER_AUDIT_SEPARATOR + path}.
+   */
+  private static final String FOLDER_AUDIT_TYPE = "metadata-virtual-folders";
+
+  private static final String FOLDER_AUDIT_SEPARATOR = "\t";
+
+  /**
+   * Audit-trail key under which the tree-panel sash weights are stored 
(global, not per-project).
+   */
+  private static final String SASH_WEIGHTS_AUDIT_TYPE = 
"metadata-perspective-tree-width";
+
   public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = 
"MetadataPerspective-Toolbar";
 
+  public static final String TOOLBAR_ITEM_NEW_TYPE = 
"MetadataPerspective-Toolbar-09000-NewType";
   public static final String TOOLBAR_ITEM_NEW = 
"MetadataPerspective-Toolbar-10000-New";
   public static final String TOOLBAR_ITEM_EDIT = 
"MetadataPerspective-Toolbar-10010-Edit";
   public static final String TOOLBAR_ITEM_DUPLICATE = 
"MetadataPerspective-Toolbar-10030-Duplicate";
@@ -144,15 +168,31 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
       "MetadataPerspective-Toolbar-10060-ExpandAll";
   public static final String TOOLBAR_ITEM_COLLAPSE_ALL =
       "MetadataPerspective-Toolbar-10070-CollapseAll";
+  public static final String TOOLBAR_ITEM_SHOW_EMPTY =
+      "MetadataPerspective-Toolbar-10080-ShowEmpty";
   public static final String TOOLBAR_ITEM_REFRESH = 
"MetadataPerspective-Toolbar-10100-Refresh";
 
   private static final String KEY_HELP = "Help";
   private static final String KEY_TYPE = "type";
   public static final String FILE = "File";
   public static final String FOLDER = "Folder";
+
+  /** A metadata type node (groups the items of one {@code @HopMetadata} 
type). */
+  public static final String TYPE = "MetadataItem";
+
+  /** A category header node grouping several metadata types. */
+  public static final String CATEGORY = "Category";
+
   public static final String VIRTUAL_PATH = "virtualPath";
   public static final String ERROR = "Error";
 
+  /** Icons for the "show empty types" toggle button (icon reflects the action 
it will perform). */
+  private static final String IMAGE_SHOW_ALL = "ui/images/show-all.svg";
+
+  private static final String IMAGE_SHOW_SELECTED = 
"ui/images/show-selected.svg";
+
+  private static final int FILTER_DEBOUNCE_MS = 250;
+
   @Getter private static MetadataPerspective instance;
 
   private HopGui hopGui;
@@ -160,10 +200,31 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   private Tree tree;
   private TreeEditor treeEditor;
   private CTabFolder tabFolder;
+  private Composite editorArea;
+  private StackLayout editorStackLayout;
+  private MetadataOverview overview;
   private GuiToolbarWidgets toolBarWidgets;
   private Text searchText;
+  private Label resultCountLabel;
   private String currentSearchFilter = "";
 
+  /** When {@code false} (the default) metadata types and categories with no 
items are hidden. */
+  private boolean showEmptyTypes = false;
+
+  /**
+   * In-memory view of all metadata types and their items; loaded once per 
{@link #reloadModel()}.
+   */
+  private final List<MetadataTypeModel> typeModels = new ArrayList<>();
+
+  /** Stable node ids whose default expand state has been seeded into 
TreeMemory this session. */
+  private final Set<String> treeStateSeeded = new HashSet<>();
+
+  /** Debounced search action so we don't rebuild the tree on every keystroke. 
*/
+  private final Runnable filterRunnable = this::applyFilter;
+
+  /** Debounced save of the tree-panel sash weights so we don't write on every 
drag pixel. */
+  private final Runnable sashWeightsSaver = this::saveSashWeights;
+
   private final List<MetadataEditor<?>> editors = new ArrayList<>();
 
   private final MetadataFileType metadataFileType;
@@ -212,19 +273,26 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     sash.setLayoutData(new FormDataBuilder().fullSize().result());
 
     createTree(sash);
-    createTabFolder(sash);
+
+    // The right-hand side stacks the editor tab folder and an 
overview/landing page; the overview
+    // is shown whenever no editor tab is open.
+    editorArea = new Composite(sash, SWT.NONE);
+    editorStackLayout = new StackLayout();
+    editorArea.setLayout(editorStackLayout);
+    PropsUi.setLook(editorArea);
+    overview = new MetadataOverview(editorArea, this);
+    createTabFolder(editorArea);
+    editorStackLayout.topControl = overview;
+    editorArea.layout();
 
     sash.setWeights(new int[] {20, 80});
+    // Restore the saved tree-panel width and persist it whenever the divider 
is dragged.
+    loadSashWeights();
+    editorArea.addListener(SWT.Resize, e -> scheduleSaveSashWeights());
 
     this.refresh();
     this.updateSelection();
 
-    // Set the top level items in the tree to be expanded
-    //
-    for (TreeItem item : tree.getItems()) {
-      TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, item, 
true);
-    }
-
     // refresh the metadata when it changes.
     //
     hopGui
@@ -273,12 +341,22 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     // Add search listener
     searchText.addListener(SWT.Modify, e -> filterTree());
 
+    // Result count shown under the search box while a filter is active (empty 
otherwise)
+    resultCountLabel = new Label(treeComposite, SWT.NONE);
+    PropsUi.setLook(resultCountLabel);
+    FormData countFormData = new FormData();
+    countFormData.left = new FormAttachment(0, 0);
+    countFormData.top = new FormAttachment(searchText, 0);
+    countFormData.right = new FormAttachment(100, 0);
+    countFormData.height = 0; // collapsed until a search filter is active 
(see updateResultCount)
+    resultCountLabel.setLayoutData(countFormData);
+
     // Create a composite with toolbar and tree for the border
     Composite composite = new Composite(treeComposite, SWT.BORDER);
     composite.setLayout(new FormLayout());
     FormData layoutData = new FormData();
     layoutData.left = new FormAttachment(0, 0);
-    layoutData.top = new FormAttachment(searchText, PropsUi.getMargin());
+    layoutData.top = new FormAttachment(resultCountLabel, PropsUi.getMargin());
     layoutData.right = new FormAttachment(100, 0);
     layoutData.bottom = new FormAttachment(100, 0);
     composite.setLayoutData(layoutData);
@@ -306,12 +384,8 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         SWT.DefaultSelection,
         event -> {
           TreeItem treeItem = tree.getSelection()[0];
-          if (treeItem != null && treeItem.getData(KEY_TYPE).equals(FILE)) {
-            if (treeItem.getParentItem() == null) {
-              onNewMetadata();
-            } else {
-              onEditMetadata();
-            }
+          if (treeItem != null && FILE.equals(treeItem.getData(KEY_TYPE))) {
+            onEditMetadata();
           }
         });
 
@@ -329,7 +403,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
             MenuItem menuItem;
 
             switch ((String) treeItem.getData(KEY_TYPE)) {
-              case "MetadataItem", FOLDER:
+              case TYPE:
                 menuItem = new MenuItem(menu, SWT.POP_UP);
                 menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.New"));
                 menuItem.addListener(SWT.Selection, e -> onNewMetadata());
@@ -338,6 +412,24 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
                 menuItem.addListener(SWT.Selection, e -> createNewFolder());
                 new MenuItem(menu, SWT.SEPARATOR);
                 break;
+              case FOLDER:
+                menuItem = new MenuItem(menu, SWT.POP_UP);
+                menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.New"));
+                menuItem.addListener(SWT.Selection, e -> onNewMetadata());
+                menuItem = new MenuItem(menu, SWT.POP_UP);
+                menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.NewFolder"));
+                menuItem.addListener(SWT.Selection, e -> createNewFolder());
+                new MenuItem(menu, SWT.SEPARATOR);
+                menuItem = new MenuItem(menu, SWT.POP_UP);
+                menuItem.setText(
+                    BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.DeleteFolder"));
+                menuItem.setImage(GuiResource.getInstance().getImageDelete());
+                menuItem.addListener(SWT.Selection, e -> onDeleteFolder());
+                new MenuItem(menu, SWT.SEPARATOR);
+                break;
+              case CATEGORY:
+                addNewTypeMenuItemsForCategory(menu, (String) 
treeItem.getData());
+                break;
               case FILE:
                 menuItem = new MenuItem(menu, SWT.POP_UP);
                 menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.Edit"));
@@ -367,10 +459,13 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
                 break;
             }
 
-            menuItem = new MenuItem(menu, SWT.POP_UP);
-            menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.Help"));
-            menuItem.setImage(GuiResource.getInstance().getImageHelp());
-            menuItem.addListener(SWT.Selection, e -> onHelpMetadata());
+            // Help is not meaningful on a category header.
+            if (!CATEGORY.equals(treeItem.getData(KEY_TYPE))) {
+              menuItem = new MenuItem(menu, SWT.POP_UP);
+              menuItem.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.Menu.Help"));
+              menuItem.setImage(GuiResource.getInstance().getImageHelp());
+              menuItem.addListener(SWT.Selection, e -> onHelpMetadata());
+            }
 
             tree.setMenu(menu);
             menu.setVisible(true);
@@ -390,8 +485,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     treeEditor.horizontalAlignment = SWT.LEFT;
     treeEditor.grabHorizontal = true;
 
-    // Remember tree node expanded/Collapsed
-    TreeMemory.addTreeListener(tree, METADATA_PERSPECTIVE_TREE);
+    // Remember expand/collapse within the session (shared TreeMemory, keyed 
by stable node ids).
+    tree.addListener(SWT.Expand, e -> recordTreeState((TreeItem) e.item, 
true));
+    tree.addListener(SWT.Collapse, e -> recordTreeState((TreeItem) e.item, 
false));
 
     // Drag and drop: reorganize within tree (same type only) and drag to 
canvas to open
     createTreeDragSource(tree);
@@ -403,11 +499,18 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
    * class item.
    */
   private String getObjectKey(TreeItem item) {
-    TreeItem root = item;
-    while (root.getParentItem() != null) {
-      root = root.getParentItem();
+    // Walk up to the nearest node that carries a metadata type key: a type 
node or a folder node
+    // (both store the key in their data). Category and file nodes do not, so 
we keep climbing.
+    // Returns null when no type ancestor exists (e.g. a category header is 
selected).
+    TreeItem current = item;
+    while (current != null) {
+      String nodeType = (String) current.getData(KEY_TYPE);
+      if (TYPE.equals(nodeType) || FOLDER.equals(nodeType)) {
+        return (String) current.getData();
+      }
+      current = current.getParentItem();
     }
-    return (String) root.getData();
+    return null;
   }
 
   private void createTreeDragSource(Tree tree) {
@@ -591,7 +694,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         SWT.Selection,
         e -> {
           if (sash.getMaximizedControl() == null) {
-            sash.setMaximizedControl(tabFolder);
+            sash.setMaximizedControl(editorArea);
             item.setImage(GuiResource.getInstance().getImageMinimizePanel());
           } else {
             sash.setMaximizedControl(null);
@@ -683,6 +786,69 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     tabFolder.setSelection(tabItem);
 
     editor.setFocus();
+
+    // An editor is open: show the tab folder instead of the overview page.
+    showEditors();
+  }
+
+  /** Shows the editor tab folder (used when at least one editor tab is open). 
*/
+  private void showEditors() {
+    if (editorStackLayout == null || editorArea == null || 
editorArea.isDisposed()) {
+      return;
+    }
+    if (editorStackLayout.topControl != tabFolder) {
+      editorStackLayout.topControl = tabFolder;
+      editorArea.layout();
+    }
+  }
+
+  /**
+   * Shows the overview/landing page (used when no editor tab is open) and 
refreshes its content.
+   */
+  private void showOverview() {
+    if (editorStackLayout == null || editorArea == null || 
editorArea.isDisposed()) {
+      return;
+    }
+    if (overview != null && !overview.isDisposed()) {
+      overview.setCards(buildOverviewCards());
+    }
+    if (editorStackLayout.topControl != overview) {
+      editorStackLayout.topControl = overview;
+      editorArea.layout();
+    }
+  }
+
+  /** Builds the overview cards (one per metadata type) from the cached model. 
*/
+  private List<MetadataOverview.TypeCard> buildOverviewCards() {
+    List<MetadataOverview.TypeCard> cards = new ArrayList<>();
+    for (MetadataTypeModel typeModel : typeModels) {
+      cards.add(
+          new MetadataOverview.TypeCard(
+              typeModel.categoryId,
+              typeModel.key,
+              typeModel.typeName,
+              typeModel.description,
+              typeModel.image,
+              typeModel.metadataClass.getClassLoader(),
+              typeModel.items.size()));
+    }
+    return cards;
+  }
+
+  /** Selects (and reveals) the tree node for the given metadata type, if it 
is currently shown. */
+  public void selectType(String key) {
+    TreeItem typeItem = findTypeItem(key);
+    if (typeItem != null) {
+      typeItem.setExpanded(true);
+      tree.setSelection(typeItem);
+      tree.showSelection();
+      updateSelection();
+    }
+  }
+
+  /** Creates a new metadata item of the given type from the overview page. */
+  public void createNewMetadataFromOverview(String key) {
+    createMetadataOfType(key, "");
   }
 
   /**
@@ -748,6 +914,101 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     }
   }
 
+  /**
+   * Global "new" button: opens a category-grouped menu of every metadata type 
so a new item can be
+   * created regardless of the current tree selection (including types whose 
node is currently
+   * hidden because it has no items).
+   */
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_NEW_TYPE,
+      toolTip = "i18n::MetadataPerspective.ToolbarElement.NewType.Tooltip",
+      image = "ui/images/add.svg")
+  public void onNewMetadataType() {
+    Menu menu = new Menu(tree);
+    addNewTypeMenuItems(menu, null);
+    // Position the drop-down just below the toolbar button.
+    ToolItem toolItem = toolBarWidgets.findToolItem(TOOLBAR_ITEM_NEW_TYPE);
+    if (toolItem != null && !toolItem.getParent().isDisposed()) {
+      ToolBar toolBar = toolItem.getParent();
+      Rectangle bounds = toolItem.getBounds();
+      Point location = toolBar.toDisplay(bounds.x, bounds.y + bounds.height);
+      menu.setLocation(location);
+    }
+    menu.setVisible(true);
+  }
+
+  /**
+   * Adds one "New &lt;type&gt;" item per metadata type to {@code menu}, 
optionally restricted to a
+   * single category. Types are grouped by category (in the configured 
category order, with a
+   * separator between groups) and keep the model's name order within a group.
+   */
+  private void addNewTypeMenuItems(Menu menu, String onlyCategoryId) {
+    List<String> categoryIds = new ArrayList<>();
+    for (MetadataTypeModel typeModel : typeModels) {
+      if ((onlyCategoryId == null || 
onlyCategoryId.equals(typeModel.categoryId))
+          && !categoryIds.contains(typeModel.categoryId)) {
+        categoryIds.add(typeModel.categoryId);
+      }
+    }
+    categoryIds.sort(
+        Comparator.comparingInt(MetadataCategories::orderOf)
+            .thenComparing(MetadataCategories::labelFor));
+
+    boolean needSeparator = false;
+    for (String categoryId : categoryIds) {
+      if (needSeparator) {
+        new MenuItem(menu, SWT.SEPARATOR);
+      }
+      for (MetadataTypeModel typeModel : typeModels) {
+        if (!typeModel.categoryId.equals(categoryId)) {
+          continue;
+        }
+        MenuItem typeMenuItem = new MenuItem(menu, SWT.POP_UP);
+        typeMenuItem.setText(
+            BaseMessages.getString(PKG, "MetadataPerspective.Menu.NewOfType", 
typeModel.typeName));
+        typeMenuItem.setImage(
+            GuiResource.getInstance()
+                .getImage(
+                    typeModel.image,
+                    typeModel.metadataClass.getClassLoader(),
+                    ConstUi.SMALL_ICON_SIZE,
+                    ConstUi.SMALL_ICON_SIZE));
+        String key = typeModel.key;
+        typeMenuItem.addListener(SWT.Selection, e -> createMetadataOfType(key, 
""));
+      }
+      needSeparator = true;
+    }
+  }
+
+  /** Adds "New &lt;type&gt;" items for a single category (used by the 
category context menu). */
+  private void addNewTypeMenuItemsForCategory(Menu menu, String categoryId) {
+    addNewTypeMenuItems(menu, categoryId);
+  }
+
+  /**
+   * Toggle button controlling whether metadata types and categories with no 
items are shown in the
+   * tree. The icon swaps to reflect the action a click will perform.
+   */
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_SHOW_EMPTY,
+      toolTip = "i18n::MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip",
+      image = IMAGE_SHOW_ALL)
+  public void onToggleShowEmpty() {
+    showEmptyTypes = !showEmptyTypes;
+    toolBarWidgets.setToolbarItemImage(
+        TOOLBAR_ITEM_SHOW_EMPTY, showEmptyTypes ? IMAGE_SHOW_SELECTED : 
IMAGE_SHOW_ALL);
+    toolBarWidgets.setToolbarItemToolTip(
+        TOOLBAR_ITEM_SHOW_EMPTY,
+        BaseMessages.getString(
+            PKG,
+            showEmptyTypes
+                ? "MetadataPerspective.ToolbarElement.HideEmpty.Tooltip"
+                : "MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip"));
+    renderTree();
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_NEW,
@@ -759,34 +1020,32 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     }
 
     TreeItem treeItem = tree.getSelection()[0];
-    if (treeItem != null) {
-      String objectKey;
-      if (treeItem.getParentItem() == null) {
-        objectKey = (String) treeItem.getData();
-      } else {
-        objectKey = (String) treeItem.getParentItem().getData();
-      }
-
-      try {
-        IHopMetadataProvider metadataProvider = hopGui.getMetadataProvider();
-        Class<IHopMetadata> metadataClass = 
metadataProvider.getMetadataClassForKey(objectKey);
-        MetadataManager<IHopMetadata> manager =
-            new MetadataManager<>(
-                HopGui.getInstance().getVariables(),
-                metadataProvider,
-                metadataClass,
-                hopGui.getShell());
-
-        manager.newMetadataWithEditor((String) treeItem.getData(VIRTUAL_PATH));
+    if (treeItem == null) {
+      return;
+    }
+    String objectKey = getObjectKey(treeItem);
+    if (objectKey == null) {
+      // A category header (or label) is selected: there is no single type to 
create here. Use the
+      // global "new" button (or its menu) to create an item of an arbitrary 
type.
+      return;
+    }
+    createMetadataOfType(objectKey, Const.NVL((String) 
treeItem.getData(VIRTUAL_PATH), ""));
+  }
 
-        hopGui.getEventsHandler().fire(HopGuiEvents.MetadataCreated.name());
-      } catch (Exception e) {
-        new ErrorDialog(
-            getShell(),
-            BaseMessages.getString(PKG, 
"MetadataPerspective.CreateMetadata.Error.Header"),
-            BaseMessages.getString(PKG, 
"MetadataPerspective.CreateMetadata.Error.Message"),
-            e);
-      }
+  /**
+   * Creates a new metadata item of the given type at the given virtual path 
and opens its editor.
+   */
+  private void createMetadataOfType(String objectKey, String virtualPath) {
+    try {
+      MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
+      manager.newMetadataWithEditor(Const.NVL(virtualPath, ""));
+      hopGui.getEventsHandler().fire(HopGuiEvents.MetadataCreated.name());
+    } catch (Exception e) {
+      new ErrorDialog(
+          getShell(),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.CreateMetadata.Error.Header"),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.CreateMetadata.Error.Message"),
+          e);
     }
     refresh();
   }
@@ -1368,8 +1627,12 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
     TreeItem treeItem = tree.getSelection()[0];
 
-    // No delete on folder
-    if (!treeItem.getData(KEY_TYPE).equals(FILE)) {
+    // Folders have their own delete handling; categories/labels can't be 
deleted.
+    if (FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+      onDeleteFolder();
+      return;
+    }
+    if (!FILE.equals(treeItem.getData(KEY_TYPE))) {
       return;
     }
 
@@ -1581,29 +1844,21 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   }
 
   public void onHelpMetadata() {
-
     if (tree.getSelectionCount() != 1) {
       return;
     }
-    String objectKey = null;
-    TreeItem treeItem = tree.getSelection()[0];
-    if (treeItem != null) {
-      if (treeItem.getParentItem() != null) {
-        treeItem = treeItem.getParentItem();
-      }
-      objectKey = (String) treeItem.getData();
+    String objectKey = getObjectKey(tree.getSelection()[0]);
+    if (objectKey == null) {
+      return;
     }
-
-    if (objectKey != null) {
-      try {
-        MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
-        HopMetadata annotation = 
manager.getManagedClass().getAnnotation(HopMetadata.class);
-        IPlugin plugin =
-            PluginRegistry.getInstance().getPlugin(MetadataPluginType.class, 
annotation.key());
-        HelpUtils.openHelp(getShell(), plugin);
-      } catch (Exception ex) {
-        new ErrorDialog(getShell(), ERROR, "Error opening URL", ex);
-      }
+    try {
+      MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
+      HopMetadata annotation = 
manager.getManagedClass().getAnnotation(HopMetadata.class);
+      IPlugin plugin =
+          PluginRegistry.getInstance().getPlugin(MetadataPluginType.class, 
annotation.key());
+      HelpUtils.openHelp(getShell(), plugin);
+    } catch (Exception ex) {
+      new ErrorDialog(getShell(), ERROR, "Error opening URL", ex);
     }
   }
 
@@ -1651,6 +1906,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     try {
       for (TreeItem item : tree.getItems()) {
         expandTreeItem(item, true);
+        recordAllTreeState(item);
       }
     } finally {
       tree.setRedraw(true);
@@ -1671,6 +1927,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     try {
       for (TreeItem item : tree.getItems()) {
         expandTreeItem(item, false);
+        recordAllTreeState(item);
       }
     } finally {
       tree.setRedraw(true);
@@ -1685,139 +1942,382 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   @GuiKeyboardShortcut(key = SWT.F5)
   @GuiOsxKeyboardShortcut(key = SWT.F5)
   public void refresh() {
-    try {
-      tree.setRedraw(false);
-      tree.removeAll();
+    reloadModel();
+    renderTree();
+    // Keep the overview catalog in sync when it is the visible panel (no 
editor open).
+    if (overview != null
+        && !overview.isDisposed()
+        && editorStackLayout != null
+        && editorStackLayout.topControl == overview) {
+      overview.setCards(buildOverviewCards());
+    }
+  }
 
-      // top level: object key
-      //
+  /**
+   * 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.
+   */
+  private void reloadModel() {
+    typeModels.clear();
+    try {
       IHopMetadataProvider metadataProvider = hopGui.getMetadataProvider();
-      List<Class<IHopMetadata>> metadataClasses = 
metadataProvider.getMetadataClasses();
-      // Sort by name
-      Collections.sort(
-          metadataClasses,
-          (cl1, cl2) -> {
-            HopMetadata a1 = HopMetadataUtil.getHopMetadataAnnotation(cl1);
-            HopMetadata a2 = HopMetadataUtil.getHopMetadataAnnotation(cl2);
-            return a1.name().compareTo(a2.name());
-          });
-
-      for (Class<IHopMetadata> metadataClass : metadataClasses) {
+      for (Class<IHopMetadata> metadataClass : 
metadataProvider.getMetadataClasses()) {
         HopMetadata annotation = 
HopMetadataUtil.getHopMetadataAnnotation(metadataClass);
-        Image image =
-            GuiResource.getInstance()
-                .getImage(
-                    annotation.image(),
-                    metadataClass.getClassLoader(),
-                    ConstUi.SMALL_ICON_SIZE,
-                    ConstUi.SMALL_ICON_SIZE);
-
-        TreeItem classItem = new TreeItem(tree, SWT.NONE);
-        classItem.setText(
-            0, Const.NVL(TranslateUtil.translate(annotation.name(), 
metadataClass), ""));
-        classItem.setImage(image);
-        classItem.setExpanded(true);
-        classItem.setData(annotation.key());
-        classItem.setData(KEY_HELP, annotation.description());
-        classItem.setData(VIRTUAL_PATH, "");
-        classItem.setData(KEY_TYPE, "MetadataItem");
-
-        // level 1: object names
-        //
+        if (annotation == null) {
+          continue;
+        }
+        MetadataTypeModel typeModel =
+            new MetadataTypeModel(
+                annotation.key(),
+                MetadataCategories.normalize(annotation.category()),
+                Const.NVL(TranslateUtil.translate(annotation.name(), 
metadataClass), ""),
+                Const.NVL(TranslateUtil.translate(annotation.description(), 
metadataClass), ""),
+                annotation.image(),
+                metadataClass);
+
         IHopMetadataSerializer<IHopMetadata> serializer =
             metadataProvider.getSerializer(metadataClass);
         List<String> names = serializer.listObjectNames();
         Collections.sort(names);
-
-        for (final String name : names) {
-          // Apply filter - skip non-matching items
-          if (!matchesFilter(name)) {
-            continue;
-          }
-
-          IHopMetadata hopMetadata;
+        for (String name : names) {
+          String virtualPath;
           try {
-            hopMetadata = serializer.load(name);
+            virtualPath = Const.NVL(serializer.load(name).getVirtualPath(), 
"");
           } catch (HopException e) {
-            // Ignore missing metadata items
+            // Ignore missing/corrupt metadata items
             LogChannel.GENERAL.logError("Error loading metadata object:" + 
name);
             continue;
           }
-          TreeItem parentItem = classItem;
-
-          if (!Utils.isEmpty(hopMetadata.getVirtualPath())) {
-            List<String> folders =
-                new 
ArrayList<>(Arrays.asList(hopMetadata.getVirtualPath().split("/")));
-            // remove empty elements
-            folders.removeAll(Arrays.asList("", null));
-
-            for (String folder : folders) {
-              TreeItem alreadyExists = null;
-              if (!folder.isEmpty()) {
-                // check if folder already exists on this level
-                alreadyExists = null;
-                for (TreeItem childItem : parentItem.getItems()) {
-                  if (childItem.getData(KEY_TYPE).equals(FOLDER)
-                      && childItem.getText().equals(folder)) {
-                    alreadyExists = childItem;
-                  }
-                }
+          typeModel.items.add(new MetadataItemModel(name, virtualPath));
+        }
+        typeModels.add(typeModel);
+      }
+      // Attach explicitly-created (persisted) virtual folders so empty ones 
survive
+      // refresh/restart.
+      loadPersistedFolders();
+      // Sort the types by their (translated) display name.
+      typeModels.sort(Comparator.comparing(typeModel -> typeModel.typeName));
+    } catch (Exception e) {
+      new ErrorDialog(
+          getShell(),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.RefreshMetadata.Error.Header"),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.RefreshMetadata.Error.Message"),
+          e);
+    }
+  }
 
-                if (alreadyExists != null) {
-                  parentItem = alreadyExists;
-                } else {
-                  TreeItem folderItem = new TreeItem(parentItem, SWT.NONE);
-                  folderItem.setText(folder);
-                  folderItem.setData(annotation.key());
-                  
folderItem.setImage(GuiResource.getInstance().getImageFolder());
-                  folderItem.setData(
-                      VIRTUAL_PATH,
-                      folderItem.getParentItem().getData(VIRTUAL_PATH) + "/" + 
folder);
-                  folderItem.setData(KEY_TYPE, FOLDER);
-
-                  // Expand folders when filtering to show matches
-                  if (!Utils.isEmpty(currentSearchFilter)) {
-                    folderItem.setExpanded(true);
-                  }
+  /**
+   * Reads the project's persisted virtual folders from the audit trail and 
attaches each path to
+   * its metadata type model, so explicitly-created (and possibly empty) 
folders are rendered.
+   */
+  private void loadPersistedFolders() {
+    Map<String, MetadataTypeModel> byKey = new LinkedHashMap<>();
+    for (MetadataTypeModel typeModel : typeModels) {
+      byKey.put(typeModel.key, typeModel);
+    }
+    try {
+      AuditList list =
+          AuditManager.getActive().retrieveList(getAuditNamespace(), 
FOLDER_AUDIT_TYPE);
+      if (list == null || list.getNames() == null) {
+        return;
+      }
+      for (String entry : list.getNames()) {
+        int sep = entry.indexOf(FOLDER_AUDIT_SEPARATOR);
+        if (sep < 0) {
+          continue;
+        }
+        MetadataTypeModel typeModel = byKey.get(entry.substring(0, sep));
+        String path = entry.substring(sep + FOLDER_AUDIT_SEPARATOR.length());
+        if (typeModel != null
+            && !Utils.isEmpty(path)
+            && !typeModel.folderVirtualPaths.contains(path)) {
+          typeModel.folderVirtualPaths.add(path);
+        }
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error reading metadata virtual folders from the 
audit trail", e);
+    }
+  }
 
-                  parentItem = folderItem;
-                }
-              }
-            }
-          }
+  /** Persists an (empty) virtual folder path for a metadata type in the 
project's audit trail. */
+  private void persistFolder(String typeKey, String virtualPath) {
+    try {
+      IAuditManager auditManager = AuditManager.getActive();
+      String namespace = getAuditNamespace();
+      AuditList list = auditManager.retrieveList(namespace, FOLDER_AUDIT_TYPE);
+      if (list == null || list.getNames() == null) {
+        list = new AuditList(new ArrayList<>());
+      }
+      String entry = typeKey + FOLDER_AUDIT_SEPARATOR + virtualPath;
+      if (!list.getNames().contains(entry)) {
+        list.getNames().add(entry);
+        auditManager.storeList(namespace, FOLDER_AUDIT_TYPE, list);
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error storing metadata virtual folder in the 
audit trail", e);
+    }
+  }
 
-          TreeItem item = new TreeItem(parentItem, SWT.NONE);
-          item.setText(0, Const.NVL(name, ""));
-          item.setData(VIRTUAL_PATH, parentItem.getData(VIRTUAL_PATH));
-          item.setData(KEY_TYPE, FILE);
-          MetadataEditor<?> editor = this.findEditor(annotation.key(), name);
-          if (editor != null && editor.hasChanged()) {
-            item.setFont(GuiResource.getInstance().getFontBold());
-          }
+  /** The audit group for the current project (falls back to the default 
HopGui namespace). */
+  private static String getAuditNamespace() {
+    return Const.NVL(HopNamespace.getNamespace(), 
HopGui.DEFAULT_HOP_GUI_NAMESPACE);
+  }
+
+  /** Restores the saved tree-panel sash weights (a global layout preference), 
if any. */
+  private void loadSashWeights() {
+    if (sash == null || sash.isDisposed()) {
+      return;
+    }
+    try {
+      AuditList list =
+          AuditManager.getActive()
+              .retrieveList(HopGui.DEFAULT_HOP_GUI_NAMESPACE, 
SASH_WEIGHTS_AUDIT_TYPE);
+      if (list == null || list.getNames() == null || list.getNames().size() < 
2) {
+        return;
+      }
+      int[] weights = new int[list.getNames().size()];
+      for (int i = 0; i < weights.length; i++) {
+        weights[i] = Integer.parseInt(list.getNames().get(i).trim());
+      }
+      sash.setWeights(weights);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error reading the metadata tree-panel width from 
the audit trail", e);
+    }
+  }
+
+  /** Debounced trigger to save the sash weights after the divider stops 
moving. */
+  private void scheduleSaveSashWeights() {
+    if (sash == null || sash.isDisposed()) {
+      return;
+    }
+    Display display = sash.getDisplay();
+    display.timerExec(-1, sashWeightsSaver);
+    display.timerExec(400, sashWeightsSaver);
+  }
+
+  /** Persists the current tree-panel sash weights (global, not per-project). 
*/
+  private void saveSashWeights() {
+    if (sash == null || sash.isDisposed()) {
+      return;
+    }
+    int[] weights = sash.getWeights();
+    if (weights == null || weights.length < 2) {
+      return;
+    }
+    try {
+      List<String> values = new ArrayList<>();
+      for (int weight : weights) {
+        values.add(Integer.toString(weight));
+      }
+      AuditManager.getActive()
+          .storeList(
+              HopGui.DEFAULT_HOP_GUI_NAMESPACE, SASH_WEIGHTS_AUDIT_TYPE, new 
AuditList(values));
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error storing the metadata tree-panel width in 
the audit trail", e);
+    }
+  }
+
+  /**
+   * Deletes the selected virtual folder: any items inside it (or its 
sub-folders) are moved up to
+   * the parent folder, and the folder (plus any persisted empty sub-folders) 
is removed from the
+   * audit trail. Items are never deleted here.
+   */
+  public void onDeleteFolder() {
+    if (tree.getSelectionCount() != 1) {
+      return;
+    }
+    TreeItem treeItem = tree.getSelection()[0];
+    if (!FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+      return;
+    }
+    String typeKey = getObjectKey(treeItem);
+    String folderPath = Const.NVL((String) treeItem.getData(VIRTUAL_PATH), "");
+    if (typeKey == null || folderPath.isEmpty()) {
+      return;
+    }
+
+    String parentPath = parentPath(folderPath);
+    List<MetadataItemModel> affected = new ArrayList<>();
+    MetadataTypeModel typeModel = findTypeModel(typeKey);
+    if (typeModel != null) {
+      for (MetadataItemModel item : typeModel.items) {
+        if (isUnderFolder(item.virtualPath, folderPath)) {
+          affected.add(item);
         }
       }
+    }
 
-      // Remove empty class items (those with no children after filtering)
-      if (!Utils.isEmpty(currentSearchFilter)) {
-        List<TreeItem> itemsToRemove = new ArrayList<>();
-        for (TreeItem classItem : tree.getItems()) {
-          if (classItem.getItemCount() == 0) {
-            itemsToRemove.add(classItem);
+    MessageBox confirm = new MessageBox(getShell(), SWT.ICON_QUESTION | 
SWT.YES | SWT.NO);
+    confirm.setText(BaseMessages.getString(PKG, 
"MetadataPerspective.DeleteFolder.Title"));
+    confirm.setMessage(
+        affected.isEmpty()
+            ? BaseMessages.getString(
+                PKG, "MetadataPerspective.DeleteFolder.Confirm", 
treeItem.getText())
+            : BaseMessages.getString(
+                PKG,
+                "MetadataPerspective.DeleteFolder.ConfirmWithItems",
+                treeItem.getText(),
+                affected.size()));
+    if ((confirm.open() & SWT.YES) == 0) {
+      return;
+    }
+
+    try {
+      if (!affected.isEmpty()) {
+        IHopMetadataProvider provider = hopGui.getMetadataProvider();
+        IHopMetadataSerializer<IHopMetadata> serializer =
+            provider.getSerializer(provider.getMetadataClassForKey(typeKey));
+        for (MetadataItemModel item : affected) {
+          IHopMetadata metadata = serializer.load(item.name);
+          // The affected paths all start with folderPath, so swap that prefix 
for the parent path.
+          metadata.setVirtualPath(parentPath + 
item.virtualPath.substring(folderPath.length()));
+          serializer.save(metadata);
+        }
+        hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+      }
+      removePersistedFoldersUnder(typeKey, folderPath);
+      refresh();
+      updateSelection();
+    } catch (Exception e) {
+      new ErrorDialog(
+          getShell(),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.DeleteFolder.Error.Header"),
+          BaseMessages.getString(PKG, 
"MetadataPerspective.DeleteFolder.Error.Message"),
+          e);
+    }
+  }
+
+  /** Returns the parent of a virtual folder path ({@code "/a/b" -> "/a"}, 
{@code "/a" -> ""}). */
+  private static String parentPath(String folderPath) {
+    int idx = folderPath.lastIndexOf('/');
+    return idx <= 0 ? "" : folderPath.substring(0, idx);
+  }
+
+  /**
+   * True when {@code itemPath} is the folder itself or sits inside it (or one 
of its sub-folders).
+   */
+  private static boolean isUnderFolder(String itemPath, String folderPath) {
+    String path = Const.NVL(itemPath, "");
+    return path.equals(folderPath) || path.startsWith(folderPath + "/");
+  }
+
+  private MetadataTypeModel findTypeModel(String typeKey) {
+    for (MetadataTypeModel typeModel : typeModels) {
+      if (typeModel.key.equals(typeKey)) {
+        return typeModel;
+      }
+    }
+    return null;
+  }
+
+  /** Drops the given folder and any persisted sub-folder beneath it from the 
audit trail. */
+  private void removePersistedFoldersUnder(String typeKey, String folderPath) {
+    try {
+      IAuditManager auditManager = AuditManager.getActive();
+      String namespace = getAuditNamespace();
+      AuditList list = auditManager.retrieveList(namespace, FOLDER_AUDIT_TYPE);
+      if (list == null || list.getNames() == null || 
list.getNames().isEmpty()) {
+        return;
+      }
+      String prefix = typeKey + FOLDER_AUDIT_SEPARATOR;
+      boolean changed =
+          list.getNames()
+              .removeIf(
+                  entry -> {
+                    if (!entry.startsWith(prefix)) {
+                      return false;
+                    }
+                    String path = entry.substring(prefix.length());
+                    return path.equals(folderPath) || 
path.startsWith(folderPath + "/");
+                  });
+      if (changed) {
+        auditManager.storeList(namespace, FOLDER_AUDIT_TYPE, list);
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error removing metadata virtual folder from the 
audit trail", e);
+    }
+  }
+
+  /**
+   * Rebuilds the tree from the in-memory model (see {@link #reloadModel()}), 
grouping types under
+   * category headers and applying the current search filter and the 
hide-empty setting. Performs no
+   * disk I/O, so it is cheap enough to run on every keystroke.
+   */
+  private void renderTree() {
+    if (tree == null || tree.isDisposed()) {
+      return;
+    }
+    try {
+      // Capture scroll (top item) and selection so a rebuild doesn't jump 
back to the top.
+      String topId = nodeIdentity(tree.getTopItem());
+      TreeItem[] selection = tree.getSelection();
+      String selectionId = selection.length > 0 ? nodeIdentity(selection[0]) : 
null;
+
+      tree.setRedraw(false);
+      tree.removeAll();
+
+      boolean filtering = !Utils.isEmpty(currentSearchFilter);
+      int totalMatches = 0;
+
+      // Group the (name-sorted) types by their category id.
+      Map<String, List<MetadataTypeModel>> typesByCategory = new 
LinkedHashMap<>();
+      for (MetadataTypeModel typeModel : typeModels) {
+        typesByCategory
+            .computeIfAbsent(typeModel.categoryId, k -> new ArrayList<>())
+            .add(typeModel);
+      }
+      // Order categories: known categories first (declaration order), then 
unknown, "Other" last.
+      List<String> categoryIds = new ArrayList<>(typesByCategory.keySet());
+      categoryIds.sort(
+          Comparator.comparingInt(MetadataCategories::orderOf)
+              .thenComparing(MetadataCategories::labelFor));
+
+      for (String categoryId : categoryIds) {
+        boolean categoryMatches = filtering && 
contains(MetadataCategories.labelFor(categoryId));
+        TreeItem categoryItem = null;
+        for (MetadataTypeModel typeModel : typesByCategory.get(categoryId)) {
+          boolean typeMatches = filtering && (categoryMatches || 
contains(typeModel.typeName));
+          List<MetadataItemModel> shownItems;
+          if (!filtering || typeMatches) {
+            shownItems = typeModel.items;
           } else {
-            // Expand class items when filtering to show matches
-            classItem.setExpanded(true);
+            shownItems = new ArrayList<>();
+            for (MetadataItemModel itemModel : typeModel.items) {
+              if (contains(itemModel.name)) {
+                shownItems.add(itemModel);
+              }
+            }
           }
-        }
-        for (TreeItem item : itemsToRemove) {
-          item.dispose();
+          boolean showType =
+              filtering
+                  ? (typeMatches || !shownItems.isEmpty())
+                  : (showEmptyTypes
+                      || !typeModel.items.isEmpty()
+                      || !typeModel.folderVirtualPaths.isEmpty());
+          if (!showType) {
+            continue;
+          }
+          totalMatches += shownItems.size();
+          if (categoryItem == null) {
+            categoryItem = createCategoryItem(categoryId);
+          }
+          buildTypeNode(categoryItem, typeModel, shownItems, filtering);
         }
       }
 
+      updateResultCount(filtering, totalMatches);
+
       TreeUtil.setOptimalWidthOnColumns(tree);
-      TreeMemory.setExpandedFromMemory(tree, METADATA_PERSPECTIVE_TREE);
+      // Apply the remembered expand/collapse state (also force-expands 
everything while filtering).
+      applyTreeMemory();
 
       tree.setRedraw(true);
 
+      // Restore the selection and scroll position captured above.
+      restoreTreeViewState(topId, selectionId);
+
       updateGui();
       updateSelection();
     } catch (Exception e) {
@@ -1829,33 +2329,283 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     }
   }
 
-  /** Filter the tree based on search text */
+  /**
+   * Stable identity for any tree node (category, type, folder or item), 
independent of display
+   * text, used to re-find a node after a rebuild. Returns {@code null} for 
unknown/disposed nodes.
+   */
+  private String nodeIdentity(TreeItem item) {
+    if (item == null || item.isDisposed()) {
+      return null;
+    }
+    String type = (String) item.getData(KEY_TYPE);
+    if (CATEGORY.equals(type)) {
+      return "C\t" + item.getData();
+    }
+    if (TYPE.equals(type)) {
+      return "T\t" + item.getData();
+    }
+    if (FOLDER.equals(type)) {
+      return "F\t" + item.getData() + "\t" + item.getData(VIRTUAL_PATH);
+    }
+    if (FILE.equals(type)) {
+      return "I\t"
+          + getObjectKey(item)
+          + "\t"
+          + item.getData(VIRTUAL_PATH)
+          + "\t"
+          + item.getText(0);
+    }
+    return null;
+  }
+
+  /**
+   * Re-selects and re-scrolls to the nodes identified by {@code selectionId} 
/ {@code topId} (as
+   * produced by {@link #nodeIdentity}) after the tree has been rebuilt. 
Missing nodes are ignored.
+   */
+  private void restoreTreeViewState(String topId, String selectionId) {
+    if (topId == null && selectionId == null) {
+      return;
+    }
+    TreeItem[] found = new TreeItem[2]; // [0] = top item, [1] = selection
+    findTreeItems(tree.getItems(), topId, selectionId, found);
+    if (found[1] != null) {
+      tree.setSelection(found[1]);
+    }
+    if (found[0] != null) {
+      tree.setTopItem(found[0]);
+    }
+  }
+
+  private void findTreeItems(TreeItem[] items, String topId, String 
selectionId, TreeItem[] found) {
+    for (TreeItem item : items) {
+      String id = nodeIdentity(item);
+      if (id != null) {
+        if (topId != null && topId.equals(id)) {
+          found[0] = item;
+        }
+        if (selectionId != null && selectionId.equals(id)) {
+          found[1] = item;
+        }
+      }
+      findTreeItems(item.getItems(), topId, selectionId, found);
+    }
+  }
+
+  /** Clears the search filter (e.g. when switching projects). */
   @Override
   public void clearSearchFilters() {
+    currentSearchFilter = "";
     if (searchText != null && !searchText.isDisposed()) {
       searchText.setText("");
-    } else {
-      currentSearchFilter = "";
     }
   }
 
+  private TreeItem createCategoryItem(String categoryId) {
+    TreeItem categoryItem = new TreeItem(tree, SWT.NONE);
+    categoryItem.setText(MetadataCategories.labelFor(categoryId));
+    categoryItem.setImage(
+        GuiResource.getInstance()
+            .getImage(
+                MetadataCategories.imageFor(categoryId),
+                getClass().getClassLoader(),
+                ConstUi.SMALL_ICON_SIZE,
+                ConstUi.SMALL_ICON_SIZE));
+    categoryItem.setData(categoryId);
+    categoryItem.setData(KEY_TYPE, CATEGORY);
+    categoryItem.setData(VIRTUAL_PATH, "");
+    return categoryItem;
+  }
+
+  /** Builds a metadata type node (with item count) and its folder/item 
subtree under a category. */
+  private void buildTypeNode(
+      TreeItem categoryItem,
+      MetadataTypeModel typeModel,
+      List<MetadataItemModel> shownItems,
+      boolean filtering) {
+    Image image =
+        GuiResource.getInstance()
+            .getImage(
+                typeModel.image,
+                typeModel.metadataClass.getClassLoader(),
+                ConstUi.SMALL_ICON_SIZE,
+                ConstUi.SMALL_ICON_SIZE);
+
+    int count = filtering ? shownItems.size() : typeModel.items.size();
+    TreeItem classItem = new TreeItem(categoryItem, SWT.NONE);
+    classItem.setText(0, typeModel.typeName + " (" + count + ")");
+    classItem.setImage(image);
+    classItem.setData(typeModel.key);
+    classItem.setData(KEY_HELP, typeModel.description);
+    classItem.setData(VIRTUAL_PATH, "");
+    classItem.setData(KEY_TYPE, TYPE);
+
+    // Materialize explicitly-created (possibly empty) folders first so they 
show without any items.
+    // Skip while filtering: empty folders never match a search.
+    if (!filtering) {
+      for (String folderPath : typeModel.folderVirtualPaths) {
+        resolveFolderItem(classItem, typeModel.key, folderPath);
+      }
+    }
+
+    for (MetadataItemModel itemModel : shownItems) {
+      TreeItem parentItem =
+          Utils.isEmpty(itemModel.virtualPath)
+              ? classItem
+              : resolveFolderItem(classItem, typeModel.key, 
itemModel.virtualPath);
+
+      TreeItem item = new TreeItem(parentItem, SWT.NONE);
+      item.setText(0, Const.NVL(itemModel.name, ""));
+      item.setData(VIRTUAL_PATH, parentItem.getData(VIRTUAL_PATH));
+      item.setData(KEY_TYPE, FILE);
+      MetadataEditor<?> editor = this.findEditor(typeModel.key, 
itemModel.name);
+      if (editor != null && editor.hasChanged()) {
+        item.setFont(GuiResource.getInstance().getFontBold());
+      }
+    }
+  }
+
+  /**
+   * Ensures the folder chain for {@code virtualPath} exists under {@code 
typeItem}, creating folder
+   * nodes as needed, and returns the deepest folder node (or {@code typeItem} 
for an empty path).
+   */
+  private TreeItem resolveFolderItem(TreeItem typeItem, String typeKey, String 
virtualPath) {
+    TreeItem parentItem = typeItem;
+    if (Utils.isEmpty(virtualPath)) {
+      return parentItem;
+    }
+    List<String> folders = new 
ArrayList<>(Arrays.asList(virtualPath.split("/")));
+    folders.removeAll(Arrays.asList("", null));
+    for (String folder : folders) {
+      if (folder.isEmpty()) {
+        continue;
+      }
+      TreeItem alreadyExists = null;
+      for (TreeItem childItem : parentItem.getItems()) {
+        if (FOLDER.equals(childItem.getData(KEY_TYPE)) && 
childItem.getText().equals(folder)) {
+          alreadyExists = childItem;
+        }
+      }
+      if (alreadyExists != null) {
+        parentItem = alreadyExists;
+      } else {
+        TreeItem folderItem = new TreeItem(parentItem, SWT.NONE);
+        folderItem.setText(folder);
+        folderItem.setData(typeKey);
+        folderItem.setImage(GuiResource.getInstance().getImageFolder());
+        folderItem.setData(
+            VIRTUAL_PATH, folderItem.getParentItem().getData(VIRTUAL_PATH) + 
"/" + folder);
+        folderItem.setData(KEY_TYPE, FOLDER);
+        parentItem = folderItem;
+      }
+    }
+    return parentItem;
+  }
+
+  /**
+   * Stable TreeMemory key for an expandable node, independent of its display 
text so a changing
+   * item count never invalidates a remembered state. Returns {@code null} for 
non-expandable nodes.
+   */
+  private String[] treeMemoryPath(TreeItem item) {
+    String type = (String) item.getData(KEY_TYPE);
+    if (CATEGORY.equals(type)) {
+      return new String[] {"C", (String) item.getData()};
+    }
+    if (TYPE.equals(type)) {
+      return new String[] {"T", (String) item.getData()};
+    }
+    if (FOLDER.equals(type)) {
+      return new String[] {"F", (String) item.getData(), (String) 
item.getData(VIRTUAL_PATH)};
+    }
+    return null;
+  }
+
+  /** Applies the remembered expand/collapse state to the whole tree (see 
{@link #renderTree()}). */
+  private void applyTreeMemory() {
+    for (TreeItem item : tree.getItems()) {
+      applyTreeMemory(item);
+    }
+  }
+
+  private void applyTreeMemory(TreeItem item) {
+    String[] path = treeMemoryPath(item);
+    if (path != null) {
+      if (!Utils.isEmpty(currentSearchFilter)) {
+        // While searching, expand everything so matches are visible (not 
recorded as a choice).
+        item.setExpanded(true);
+      } else {
+        // Categories expand by default; types and folders collapse by 
default. Seed each
+        // default-expanded node once per session so the default holds until 
the user changes it.
+        boolean defaultExpanded = "C".equals(path[0]);
+        if (defaultExpanded && treeStateSeeded.add(String.join("�", path))) {
+          TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, 
path, true);
+        }
+        
item.setExpanded(TreeMemory.getInstance().isExpanded(METADATA_PERSPECTIVE_TREE, 
path));
+      }
+    }
+    for (TreeItem child : item.getItems()) {
+      applyTreeMemory(child);
+    }
+  }
+
+  /** Records a user expand/collapse into the shared TreeMemory (ignored while 
searching). */
+  private void recordTreeState(TreeItem item, boolean expanded) {
+    if (item == null || item.isDisposed() || 
!Utils.isEmpty(currentSearchFilter)) {
+      return;
+    }
+    String[] path = treeMemoryPath(item);
+    if (path != null) {
+      TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, path, 
expanded);
+      treeStateSeeded.add(String.join("�", path));
+    }
+  }
+
+  /**
+   * Records the current expand state of a node and its children (used after 
expand/collapse all).
+   */
+  private void recordAllTreeState(TreeItem item) {
+    recordTreeState(item, item.getExpanded());
+    for (TreeItem child : item.getItems()) {
+      recordAllTreeState(child);
+    }
+  }
+
+  /** Case-insensitive containment test of {@code text} against the current 
search filter. */
+  private boolean contains(String text) {
+    return text != null && 
text.toLowerCase().contains(currentSearchFilter.toLowerCase());
+  }
+
+  /** Filter the tree based on search text, debounced so we don't rebuild on 
every keystroke. */
   protected void filterTree() {
     if (searchText == null || searchText.isDisposed()) {
       return;
     }
+    Display display = searchText.getDisplay();
+    display.timerExec(-1, filterRunnable);
+    display.timerExec(FILTER_DEBOUNCE_MS, filterRunnable);
+  }
 
+  private void applyFilter() {
+    if (searchText == null || searchText.isDisposed()) {
+      return;
+    }
     currentSearchFilter = searchText.getText();
-
-    // Refresh to rebuild the tree with or without filter
-    refresh();
+    renderTree();
   }
 
-  /** Check if a metadata name matches the current filter */
-  private boolean matchesFilter(String name) {
-    if (Utils.isEmpty(currentSearchFilter)) {
-      return true;
+  /** Updates the "n results" label shown under the search box while a filter 
is active. */
+  private void updateResultCount(boolean filtering, int matches) {
+    if (resultCountLabel == null || resultCountLabel.isDisposed()) {
+      return;
+    }
+    resultCountLabel.setText(
+        filtering
+            ? BaseMessages.getString(PKG, 
"MetadataPerspective.Search.ResultCount", matches)
+            : "");
+    // Collapse the label's row when no filter is active so there is no gap 
above the tree.
+    if (resultCountLabel.getLayoutData() instanceof FormData formData) {
+      formData.height = filtering ? SWT.DEFAULT : 0;
     }
-    return name != null && 
name.toLowerCase().contains(currentSearchFilter.toLowerCase());
+    resultCountLabel.getParent().layout();
   }
 
   /** Recursively expand or collapse a tree item and all its children */
@@ -1869,21 +2619,24 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   protected void updateSelection() {
 
     boolean isMetadataSelected = false;
-    boolean isAnythingSelected = false;
+    boolean isFolderSelected = false;
+    boolean canCreateHere = false;
 
     if (tree.getSelectionCount() > 0) {
-      isAnythingSelected = true;
       TreeItem treeItem = tree.getSelection()[0];
-      if (treeItem.getData(KEY_TYPE).equals(FILE)) {
-        isMetadataSelected = true;
-      }
+      String nodeType = (String) treeItem.getData(KEY_TYPE);
+      isMetadataSelected = FILE.equals(nodeType);
+      isFolderSelected = FOLDER.equals(nodeType);
+      // The context "New" applies to a type, folder or file (all resolve to a 
type key), but not
+      // to a category header or a plain label.
+      canCreateHere = getObjectKey(treeItem) != null;
     }
 
-    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_NEW, isAnythingSelected);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_NEW, canCreateHere);
     toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_EDIT, isMetadataSelected);
     toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_RENAME, isMetadataSelected);
     toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DUPLICATE, 
isMetadataSelected);
-    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DELETE, isMetadataSelected);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DELETE, isMetadataSelected 
|| isFolderSelected);
   }
 
   @Override
@@ -1908,6 +2661,8 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         //
         if (tabFolder.getItemCount() == 0) {
           HopGui.getInstance().handleFileCapabilities(new EmptyFileType(), 
false, false, false);
+          // Bring back the overview/landing page.
+          showOverview();
         }
 
         // Update Gui menu and toolbar
@@ -2011,40 +2766,41 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     return annotation.key();
   }
 
-  public void goToType(Class<? extends IHopMetadata> managedClass) {
-    String key = getKeyOfMetadataClass(managedClass);
-    // Look at all the top level items in the tree
-    //
-    for (TreeItem item : tree.getItems()) {
-      String classKey = (String) item.getData();
-      if (key.equals(classKey)) {
-        // Found the item.
-        //
-        tree.setSelection(item);
-        tree.showSelection();
-        return;
+  /** Finds the tree node for a metadata type (by key), searching inside the 
category headers. */
+  private TreeItem findTypeItem(String key) {
+    for (TreeItem categoryItem : tree.getItems()) {
+      for (TreeItem typeItem : categoryItem.getItems()) {
+        if (TYPE.equals(typeItem.getData(KEY_TYPE)) && 
key.equals(typeItem.getData())) {
+          return typeItem;
+        }
       }
     }
+    return null;
+  }
+
+  public void goToType(Class<? extends IHopMetadata> managedClass) {
+    TreeItem typeItem = findTypeItem(getKeyOfMetadataClass(managedClass));
+    if (typeItem != null) {
+      tree.setSelection(typeItem);
+      tree.showSelection();
+    }
   }
 
   public void goToElement(Class<? extends IHopMetadata> managedClass, String 
elementName) {
-    String key = getKeyOfMetadataClass(managedClass);
-    for (TreeItem item : tree.getItems()) {
-      String classKey = (String) item.getData();
-      if (key.equals(classKey)) {
-        // Found the type.
-        //
-        for (TreeItem elementItem : item.getItems()) {
-          if (elementName.equals(elementItem.getText())) {
-            tree.setSelection(elementItem);
-            tree.showSelection();
-            onEditMetadata();
-            return;
-          }
-        }
-        goToType(managedClass);
+    TreeItem typeItem = findTypeItem(getKeyOfMetadataClass(managedClass));
+    if (typeItem == null) {
+      return;
+    }
+    for (TreeItem elementItem : typeItem.getItems()) {
+      if (elementName.equals(elementItem.getText())) {
+        tree.setSelection(elementItem);
+        tree.showSelection();
+        onEditMetadata();
+        return;
       }
     }
+    // Element not directly under the type node (or not loaded): at least 
reveal the type.
+    goToType(managedClass);
   }
 
   public void createNewFolder() {
@@ -2053,6 +2809,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
       return;
     }
     TreeItem item = selection[0];
+    String typeKey = getObjectKey(item);
+    if (typeKey == null) {
+      return; // A category header or label is selected: nothing to add a 
folder to.
+    }
+    String parentPath = Const.NVL((String) item.getData(VIRTUAL_PATH), "");
     EnterStringDialog dialog =
         new EnterStringDialog(
             getShell(),
@@ -2061,34 +2822,65 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
             BaseMessages.getString(
                 PKG,
                 "MetadataPerspective.CreateFolder.Message",
-                ((String) item.getData(VIRTUAL_PATH)).isEmpty()
-                    ? item.getText()
-                    : (String) item.getData(VIRTUAL_PATH)));
+                parentPath.isEmpty() ? item.getText() : parentPath));
     String folder = dialog.open();
-    if (!Utils.isEmpty(folder)) {
-      for (TreeItem treeItem : item.getItems()) {
-        if (folder.equals(treeItem.getText()) && 
treeItem.getData("type").equals(FOLDER)) {
-          MessageBox msgDialog = new MessageBox(getShell(), 
SWT.ICON_INFORMATION | SWT.OK);
-          msgDialog.setText(
-              BaseMessages.getString(PKG, 
"MetadataPerspective.CreateFolder.Error.Header"));
-          msgDialog.setMessage(
-              BaseMessages.getString(PKG, 
"MetadataPerspective.CreateFolder.Error.Message"));
-          msgDialog.open();
-          return;
-        }
+    if (Utils.isEmpty(folder)) {
+      return;
+    }
+    // Reject a duplicate folder name on this level.
+    for (TreeItem treeItem : item.getItems()) {
+      if (folder.equals(treeItem.getText()) && 
FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+        MessageBox msgDialog = new MessageBox(getShell(), SWT.ICON_INFORMATION 
| SWT.OK);
+        msgDialog.setText(
+            BaseMessages.getString(PKG, 
"MetadataPerspective.CreateFolder.Error.Header"));
+        msgDialog.setMessage(
+            BaseMessages.getString(PKG, 
"MetadataPerspective.CreateFolder.Error.Message"));
+        msgDialog.open();
+        return;
       }
-      TreeItem newFolder = new TreeItem(item, SWT.NONE);
-      newFolder.setText(folder);
-      newFolder.setData(item.getData());
-      newFolder.setImage(GuiResource.getInstance().getImageFolder());
-      newFolder.setData(VIRTUAL_PATH, item.getData(VIRTUAL_PATH) + "/" + 
folder);
-      newFolder.setData(KEY_TYPE, FOLDER);
-      TreeItem emptyString = new TreeItem(newFolder, SWT.NONE);
-      emptyString.setText(
-          BaseMessages.getString(PKG, 
"MetadataPerspective.CreateFolder.EmptyFolder"));
-      emptyString.setData(KEY_TYPE, "Label");
-      
emptyString.setForeground(tree.getDisplay().getSystemColor(SWT.COLOR_GRAY));
-      newFolder.setExpanded(true);
+    }
+    // Persist the folder so it survives refresh/restart, then rebuild the 
tree from the model.
+    persistFolder(typeKey, parentPath + "/" + folder);
+    refresh();
+  }
+
+  /** In-memory view of one metadata type and its items, loaded once per 
{@link #reloadModel()}. */
+  private static final class MetadataTypeModel {
+    private final String key;
+    private final String categoryId;
+    private final String typeName;
+    private final String description;
+    private final String image;
+    private final Class<IHopMetadata> metadataClass;
+    private final List<MetadataItemModel> items = new ArrayList<>();
+
+    /** Explicitly created virtual folder paths (persisted), shown even when 
they hold no items. */
+    private final List<String> folderVirtualPaths = new ArrayList<>();
+
+    private MetadataTypeModel(
+        String key,
+        String categoryId,
+        String typeName,
+        String description,
+        String image,
+        Class<IHopMetadata> metadataClass) {
+      this.key = key;
+      this.categoryId = categoryId;
+      this.typeName = typeName;
+      this.description = description;
+      this.image = image;
+      this.metadataClass = metadataClass;
+    }
+  }
+
+  /** In-memory view of a single metadata item: its name and (optional) 
virtual folder path. */
+  private static final class MetadataItemModel {
+    private final String name;
+    private final String virtualPath;
+
+    private MetadataItemModel(String name, String virtualPath) {
+      this.name = name;
+      this.virtualPath = virtualPath;
     }
   }
 }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
index f016a85788..41f7829dd9 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
@@ -69,4 +69,28 @@ MetadataPerspective.DeleteMetadata.Button.Details=Details
 MetadataPerspective.DeleteMetadata.Details.Title=References to {0}
 MetadataPerspective.DeleteMetadata.Details.Message=The following files and 
metadata objects still reference ''{0}'':
 MetadataPerspective.DragDropMove.Error=Error moving metadata to folder
-MetadataPerspective.DragDropOpen.Error=Error opening metadata
\ No newline at end of file
+MetadataPerspective.DragDropOpen.Error=Error opening metadata
+MetadataPerspective.Menu.NewOfType={0}
+MetadataPerspective.Menu.DeleteFolder=Delete folder
+MetadataPerspective.DeleteFolder.Title=Delete folder
+MetadataPerspective.DeleteFolder.Confirm=Delete folder ''{0}''?
+MetadataPerspective.DeleteFolder.ConfirmWithItems=Delete folder 
''{0}''?\n\n{1} item(s) inside will be moved to the parent folder.
+MetadataPerspective.DeleteFolder.Error.Header=Error
+MetadataPerspective.DeleteFolder.Error.Message=Error deleting folder
+MetadataPerspective.ToolbarElement.NewType.Tooltip=Create a new metadata 
element of any type
+MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip=Show empty types and 
categories
+MetadataPerspective.ToolbarElement.HideEmpty.Tooltip=Hide empty types and 
categories
+MetadataPerspective.Search.ResultCount={0} result(s)
+MetadataPerspective.Category.Connections=Connections
+MetadataPerspective.Category.FileStorage=File Storage
+MetadataPerspective.Category.RunConfig=Run Configurations
+MetadataPerspective.Category.Servers=Servers & Web Services
+MetadataPerspective.Category.Execution=Execution & Monitoring
+MetadataPerspective.Category.Logging=Logging & Probes
+MetadataPerspective.Category.Testing=Testing
+MetadataPerspective.Category.DataDefinition=Data Definitions
+MetadataPerspective.Category.Variables=Variables
+MetadataPerspective.Category.Other=Other
+MetadataPerspective.Overview.Title=Metadata
+MetadataPerspective.Overview.Subtitle=Select a type to create a new item, or 
pick an existing item from the tree on the left.
+MetadataPerspective.Overview.New.Button=New
\ No newline at end of file
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
new file mode 100644
index 0000000000..b46cf62f72
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+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 org.apache.hop.metadata.api.HopMetadataCategory;
+import org.junit.jupiter.api.Test;
+
+class MetadataCategoriesTest {
+
+  @Test
+  void normalize_nullAndEmptyBecomeOther() {
+    assertEquals(MetadataCategories.OTHER, MetadataCategories.normalize(null));
+    assertEquals(MetadataCategories.OTHER, MetadataCategories.normalize(""));
+    assertEquals(
+        HopMetadataCategory.CONNECTIONS,
+        MetadataCategories.normalize(HopMetadataCategory.CONNECTIONS));
+  }
+
+  @Test
+  void orderOf_knownCategoriesFollowDeclarationOrder() {
+    assertEquals(0, 
MetadataCategories.orderOf(HopMetadataCategory.CONNECTIONS));
+    assertEquals(1, 
MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE));
+    // Connections sorts before file storage, file storage before variables.
+    assertTrue(
+        MetadataCategories.orderOf(HopMetadataCategory.CONNECTIONS)
+            < MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE));
+    assertTrue(
+        MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE)
+            < MetadataCategories.orderOf(HopMetadataCategory.VARIABLES));
+  }
+
+  @Test
+  void orderOf_unknownComesAfterKnownButBeforeOther() {
+    int known = MetadataCategories.orderOf(HopMetadataCategory.VARIABLES);
+    int unknown = MetadataCategories.orderOf("some-third-party-category");
+    int other = MetadataCategories.orderOf(MetadataCategories.OTHER);
+
+    assertTrue(known < unknown, "a known category must sort before an unknown 
one");
+    assertTrue(unknown < other, "an unknown category must sort before the 
Other bucket");
+    assertEquals(Integer.MAX_VALUE, other);
+    // null normalizes to the Other bucket.
+    assertEquals(other, MetadataCategories.orderOf(null));
+  }
+
+  @Test
+  void imageFor_knownReturnsConfiguredIconOthersFallBack() {
+    assertEquals(
+        "ui/images/database.svg", 
MetadataCategories.imageFor(HopMetadataCategory.CONNECTIONS));
+    assertEquals(
+        "ui/images/location.svg", 
MetadataCategories.imageFor(HopMetadataCategory.FILE_STORAGE));
+    // Unknown and Other fall back to the generic metadata icon.
+    assertEquals(
+        "ui/images/metadata.svg", 
MetadataCategories.imageFor("some-third-party-category"));
+    assertEquals("ui/images/metadata.svg", 
MetadataCategories.imageFor(MetadataCategories.OTHER));
+  }
+
+  @Test
+  void labelFor_unknownReturnsRawIdKnownReturnsTranslatedLabel() {
+    // An unknown id is shown verbatim (no i18n key exists for it).
+    assertEquals(
+        "some-third-party-category", 
MetadataCategories.labelFor("some-third-party-category"));
+    // A known id resolves to a real label, not the raw id or a blank string.
+    String connectionsLabel = 
MetadataCategories.labelFor(HopMetadataCategory.CONNECTIONS);
+    assertFalse(connectionsLabel == null || connectionsLabel.isEmpty());
+    assertFalse(HopMetadataCategory.CONNECTIONS.equals(connectionsLabel));
+  }
+}


Reply via email to