This is an automated email from the ASF dual-hosted git repository.

bamaer 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 be34e499a7 Issue #2703 : Group projects by topic in the project menu 
(#8677)
be34e499a7 is described below

commit be34e499a73936f62945e73d8f5f9d72946fa49d
Author: Matt Casters <[email protected]>
AuthorDate: Thu Oct 1 09:09:26 2026 +0200

    Issue #2703 : Group projects by topic in the project menu (#8677)
---
 .../ROOT/pages/projects/projects-environments.adoc |   3 +
 .../hop/projects/gui/ProjectConfigDialog.java      |   2 +
 .../apache/hop/projects/gui/ProjectMenuGroups.java | 146 +++++++++++++++++++++
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |  70 ++++++++--
 .../apache/hop/projects/project/ProjectDialog.java |  24 ++++
 .../gui/messages/messages_en_US.properties         |   1 +
 .../project/messages/messages_en_US.properties     |   2 +
 .../hop/projects/gui/ProjectMenuGroupsTest.java    | 106 +++++++++++++++
 8 files changed, 345 insertions(+), 9 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index cbe0e847a5..0894a872e5 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -49,6 +49,7 @@ image:hop-gui/environment/create-project-dialog.png[Project 
Properties Basic tab
 |Home folder|The folder where the project is located. Paths starting with `~` 
(e.g. `~/my-project` or `~\my-project` on Windows) automatically resolve to the 
user's home directory (`+${user.home}+`).|Yes|No|
 |This project is read only|When checked, Hop does not write 
`project-config.json` (archives, HTTP, and similar)|No|No|unchecked
 |Configuration file (relative path)|The project's configuration json, relative 
to the home folder.|Yes|Yes|`project-config.json`
+|Group|Optional topic stored on the project registration in `hop-config.json` 
(not in `project-config.json`). Projects that share a group are listed together 
in a submenu of the project menu. Leave empty to keep the project in the recent 
list.|Yes|No|
 |Description|A description for this project|No|No|
 |Company|The company this project belongs to|No|No|
 |Department|The department this project is created for|No|No|
@@ -239,6 +240,8 @@ These checks run on the JVM that enables the environment. 
They do not validate r
 
 Click the project indicator at the bottom left and choose a recent project 
from the menu, or *More projects...* to pick from a list.
 
+Projects with a group are not repeated in that recent list. Each group is its 
own submenu and lists every project in the group, so a long flat list becomes 
one entry per topic. Set the group on the project properties *Basic* tab, or 
when editing the project registration from *More projects...*. Projects without 
a group stay in the recent list (up to 15).
+
 image:hop-gui/environment/switch-project-list.png[Switch project]
 
 After switching to a project, click the environment indicator (cube icon) next 
to the project name and choose an environment from the menu.
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
index 6b3b9f69e1..c84f470034 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
@@ -182,6 +182,7 @@ public class ProjectConfigDialog extends Dialog {
     Label wlGroup = new Label(shell, SWT.RIGHT);
     PropsUi.setLook(wlGroup);
     wlGroup.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Group"));
+    wlGroup.setToolTipText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Group.Tooltip"));
     FormData fdlGroup = new FormData();
     fdlGroup.left = new FormAttachment(0, 0);
     fdlGroup.right = new FormAttachment(middle, 0);
@@ -189,6 +190,7 @@ public class ProjectConfigDialog extends Dialog {
     wlGroup.setLayoutData(fdlGroup);
     wGroup = new ComboVar(variables, shell, SWT.SINGLE | SWT.BORDER | 
SWT.LEFT);
     PropsUi.setLook(wGroup);
+    wGroup.setToolTipText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Group.Tooltip"));
     FormData fdGroup = new FormData();
     fdGroup.left = new FormAttachment(middle, margin);
     fdGroup.right = new FormAttachment(100, 0);
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectMenuGroups.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectMenuGroups.java
new file mode 100644
index 0000000000..a03c0a8318
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectMenuGroups.java
@@ -0,0 +1,146 @@
+/*
+ * 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
+ *
+ *       https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.gui;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.projects.project.ProjectConfig;
+
+/**
+ * How registered projects are split between the recent list and group 
submenus.
+ *
+ * <p>A project with a group topic is not repeated in the recent list. It is 
listed under that topic
+ * instead, so a long project list collapses to one menu entry per topic.
+ */
+public final class ProjectMenuGroups {
+
+  private ProjectMenuGroups() {}
+
+  /**
+   * @param projectConfig registration, or null
+   * @return trimmed group topic, or an empty string when the project is 
ungrouped
+   */
+  public static String groupOf(ProjectConfig projectConfig) {
+    if (projectConfig == null) {
+      return "";
+    }
+    return StringUtils.trimToEmpty(projectConfig.getGroup());
+  }
+
+  /**
+   * @param projectConfig registration, or null
+   * @return true when the project belongs in a group submenu
+   */
+  public static boolean isGrouped(ProjectConfig projectConfig) {
+    return StringUtils.isNotEmpty(groupOf(projectConfig));
+  }
+
+  /**
+   * Projects that share a group topic.
+   *
+   * <p>The map iteration order is the topic names sorted case-insensitively. 
Names inside a topic
+   * are sorted the same way. Blank topics, blank names and null registrations 
are omitted.
+   * Whitespace around a topic is ignored, so {@code " Clients "} joins {@code 
"Clients"}. Topics
+   * that differ only by case stay separate. A name is listed once per topic.
+   *
+   * @param projects registered projects, or null
+   * @return topic to project names; empty when nothing is grouped
+   */
+  public static Map<String, List<String>> byGroup(List<ProjectConfig> 
projects) {
+    if (projects == null || projects.isEmpty()) {
+      return Map.of();
+    }
+    Map<String, List<String>> exact = new LinkedHashMap<>();
+    for (ProjectConfig projectConfig : projects) {
+      if (projectConfig == null) {
+        continue;
+      }
+      String group = groupOf(projectConfig);
+      String name = StringUtils.trimToEmpty(projectConfig.getProjectName());
+      if (StringUtils.isEmpty(group) || StringUtils.isEmpty(name)) {
+        continue;
+      }
+      List<String> names = exact.computeIfAbsent(group, key -> new 
ArrayList<>());
+      if (!containsIgnoreCase(names, name)) {
+        names.add(name);
+      }
+    }
+    if (exact.isEmpty()) {
+      return Map.of();
+    }
+    List<String> labels = new ArrayList<>(exact.keySet());
+    labels.sort(String.CASE_INSENSITIVE_ORDER);
+    Map<String, List<String>> ordered = new LinkedHashMap<>();
+    for (String label : labels) {
+      List<String> names = exact.get(label);
+      names.sort(String.CASE_INSENSITIVE_ORDER);
+      ordered.put(label, names);
+    }
+    return ordered;
+  }
+
+  /**
+   * Flat recent-menu entries.
+   *
+   * <p>Only the first {@code max} names are considered, which is the same 
window the menu used
+   * before grouping. Names in that window that have a group topic are left 
out (they belong in
+   * {@link #byGroup}). A missing registration is treated as ungrouped. Empty 
names are skipped but
+   * still consume a slot in the window.
+   *
+   * @param recentNames recent project names, most relevant first, or null
+   * @param lookup registration for a name; null treats every name as ungrouped
+   * @param max number of leading names to consider
+   * @return names to show as top-level recent items, in the same order
+   */
+  public static List<String> recentUngrouped(
+      List<String> recentNames, Function<String, ProjectConfig> lookup, int 
max) {
+    if (recentNames == null || recentNames.isEmpty() || max <= 0) {
+      return List.of();
+    }
+    List<String> shown = new ArrayList<>();
+    int considered = 0;
+    for (String name : recentNames) {
+      if (considered == max) {
+        break;
+      }
+      considered++;
+      if (StringUtils.isEmpty(name)) {
+        continue;
+      }
+      ProjectConfig projectConfig = lookup == null ? null : lookup.apply(name);
+      if (isGrouped(projectConfig)) {
+        continue;
+      }
+      shown.add(name);
+    }
+    return shown;
+  }
+
+  private static boolean containsIgnoreCase(List<String> names, String name) {
+    for (String existing : names) {
+      if (existing.equalsIgnoreCase(name)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 10574d65b8..9d81c1e0a8 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -744,7 +744,8 @@ public class ProjectsGuiPlugin {
           new ProjectDialog(
               hopGui.getActiveShell(), project, projectConfig, 
hopGui.getVariables(), true);
       if (projectDialog.open() != null) {
-        // Persist project registration (name, home, config path, read-only) 
in hop-config.json.
+        // Persist project registration (name, home, config path, group, 
read-only) in
+        // hop-config.json.
         // A rename also updates the projects using this one as their parent, 
all or nothing.
         //
         ProjectsUtil.saveProjectConfig(
@@ -897,7 +898,8 @@ public class ProjectsGuiPlugin {
 
     new MenuItem(menu, SWT.SEPARATOR);
 
-    // Display the last-used projects that are still registered in hop-config.
+    // Recent projects that have no group. Grouped projects are listed under 
their topic below,
+    // not in this flat list, so the menu stays short when projects share a 
topic.
     // The in-memory list can briefly lag a deletion; drop names that are 
already gone.
     List<String> names = new ArrayList<>(getLastUsedProjects());
     List<String> registeredNames = 
ProjectsConfigSingleton.getConfig().listProjectConfigNames();
@@ -916,13 +918,32 @@ public class ProjectsGuiPlugin {
     }
 
     String currentProjectName = HopNamespace.getNamespace();
-
-    for (String name : names) {
-      MenuItem item = new MenuItem(menu, SWT.NONE);
-      item.setText(name);
-      item.addListener(SWT.Selection, e -> selectProject(name));
-      if (currentProjectName.equalsIgnoreCase(name)) {
-        item.setImage(GuiResource.getInstance().getImageCheck());
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    List<String> recent =
+        ProjectMenuGroups.recentUngrouped(
+            names, config::findProjectConfig, LAST_USED_PROJECTS_MAX_ENTRIES);
+    for (String name : recent) {
+      addProjectMenuItem(menu, name, currentProjectName);
+    }
+
+    Map<String, List<String>> groups = groupedProjectNames(config);
+    if (!recent.isEmpty() && !groups.isEmpty()) {
+      new MenuItem(menu, SWT.SEPARATOR);
+    }
+    for (Map.Entry<String, List<String>> entry : groups.entrySet()) {
+      MenuItem groupItem = new MenuItem(menu, SWT.CASCADE);
+      groupItem.setText(entry.getKey());
+      Menu subMenu = new Menu(menu);
+      groupItem.setMenu(subMenu);
+      boolean currentInGroup = false;
+      for (String name : entry.getValue()) {
+        addProjectMenuItem(subMenu, name, currentProjectName);
+        if (isCurrentProject(currentProjectName, name)) {
+          currentInGroup = true;
+        }
+      }
+      if (currentInGroup) {
+        groupItem.setImage(GuiResource.getInstance().getImageCheck());
       }
     }
 
@@ -935,6 +956,37 @@ public class ProjectsGuiPlugin {
     return menu;
   }
 
+  private void addProjectMenuItem(Menu menu, String name, String 
currentProjectName) {
+    MenuItem item = new MenuItem(menu, SWT.NONE);
+    item.setText(name);
+    item.addListener(SWT.Selection, e -> selectProject(name));
+    if (isCurrentProject(currentProjectName, name)) {
+      item.setImage(GuiResource.getInstance().getImageCheck());
+    }
+  }
+
+  private static boolean isCurrentProject(String currentProjectName, String 
name) {
+    return StringUtils.isNotEmpty(currentProjectName) && 
currentProjectName.equalsIgnoreCase(name);
+  }
+
+  /**
+   * Group topics to show in the project menu. Projects the current user may 
not open are omitted.
+   */
+  private static Map<String, List<String>> groupedProjectNames(ProjectsConfig 
config) {
+    if (config == null || config.getProjectConfigurations() == null) {
+      return Map.of();
+    }
+    List<ProjectConfig> visible = new ArrayList<>();
+    for (ProjectConfig projectConfig : config.getProjectConfigurations()) {
+      if (projectConfig == null
+          || 
!ProjectsAccessControl.isProjectAllowed(projectConfig.getProjectName())) {
+        continue;
+      }
+      visible.add(projectConfig);
+    }
+    return ProjectMenuGroups.byGroup(visible);
+  }
+
   private Menu createEnvironmentContextMenu() {
     Shell shell = HopGui.getInstance().getActiveShell();
     Menu menu = new Menu(shell, SWT.POP_UP);
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index 2ab2d37142..abf07787a8 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -90,6 +90,7 @@ public class ProjectDialog extends Dialog {
   private ComboVar wParentProject;
   private TextVar wConfigFile;
   private Button wbConfigFile;
+  private ComboVar wGroup;
   private Text wDescription;
   private Text wCompany;
   private Text wDepartment;
@@ -352,6 +353,25 @@ public class ProjectDialog extends Dialog {
     wConfigFile.setLayoutData(fdConfigFile);
     lastControl = wConfigFile;
 
+    Label wlGroup = new Label(comp, SWT.RIGHT);
+    PropsUi.setLook(wlGroup);
+    wlGroup.setText(BaseMessages.getString(PKG, "ProjectDialog.Label.Group"));
+    wlGroup.setToolTipText(BaseMessages.getString(PKG, 
"ProjectDialog.Label.Group.Tooltip"));
+    FormData fdlGroup = new FormData();
+    fdlGroup.left = new FormAttachment(0, 0);
+    fdlGroup.right = new FormAttachment(middle, 0);
+    fdlGroup.top = new FormAttachment(lastControl, margin);
+    wlGroup.setLayoutData(fdlGroup);
+    wGroup = new ComboVar(variables, comp, SWT.SINGLE | SWT.BORDER | SWT.LEFT);
+    PropsUi.setLook(wGroup);
+    wGroup.setToolTipText(BaseMessages.getString(PKG, 
"ProjectDialog.Label.Group.Tooltip"));
+    FormData fdGroup = new FormData();
+    fdGroup.left = new FormAttachment(middle, margin);
+    fdGroup.right = new FormAttachment(100, 0);
+    fdGroup.top = new FormAttachment(wlGroup, 0, SWT.CENTER);
+    wGroup.setLayoutData(fdGroup);
+    lastControl = wGroup;
+
     lastControl =
         addLabeledText(
             comp,
@@ -1005,6 +1025,9 @@ public class ProjectDialog extends Dialog {
     wName.setText(Const.NVL(projectConfig.getProjectName(), ""));
     wHome.setText(Const.NVL(projectConfig.getProjectHome(), ""));
     wConfigFile.setText(Const.NVL(projectConfig.getConfigFilename(), ""));
+    wGroup.setText(Const.NVL(projectConfig.getGroup(), ""));
+    List<String> groups = 
ProjectsConfigSingleton.getConfig().listProjectGroups();
+    wGroup.setItems(groups.toArray(new String[0]));
     wReadOnly.setSelection(
         projectConfig.isReadOnly()
             || 
ProjectConfig.isArchiveUri(variables.resolve(projectConfig.getProjectHome())));
@@ -1079,6 +1102,7 @@ public class ProjectDialog extends Dialog {
     projectConfig.setProjectName(wName.getText());
     projectConfig.setProjectHome(sanitizePath(wHome.getText()));
     projectConfig.setConfigFilename(wConfigFile.getText());
+    projectConfig.setGroup(StringUtils.trimToEmpty(wGroup.getText()));
     projectConfig.setReadOnly(wReadOnly.getSelection());
 
     project.setParentProjectName(wParentProject.getText());
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
index 481988830a..0e1c909b72 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
@@ -86,6 +86,7 @@ ProjectConfigDialog.Label.Name=Name
 ProjectConfigDialog.Label.Home=Home folder
 ProjectConfigDialog.Label.ConfigFile=Configuration file (relative path)
 ProjectConfigDialog.Label.Group=Group
+ProjectConfigDialog.Label.Group.Tooltip=Optional topic. Projects that share a 
group are listed together under that topic in the project menu, instead of in 
the recent list. Leave empty to keep this project in the recent list.
 ProjectConfigDialog.Label.Tags=Tags
 ProjectConfigDialog.Label.Tags.Tooltip=Comma-separated tags (e.g. etl, 
customer-a, legacy)
 ProjectConfigDialog.Label.ReadOnly=This project is read only
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index 5f0b5bd38f..ea42215d19 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -53,6 +53,8 @@ ProjectDialog.Label.DatasetCSVFolder=Data Sets CSV Folder 
(HOP_DATASETS_FOLDER)
 ProjectDialog.Label.Department=Department
 ProjectDialog.Label.Description=Description
 ProjectDialog.Label.EnforceExecutionInHome=Enforce executions in project home
+ProjectDialog.Label.Group=Group
+ProjectDialog.Label.Group.Tooltip=Optional topic. Projects that share a group 
are listed together under that topic in the project menu, instead of in the 
recent list. Leave empty to keep this project in the recent list.
 ProjectDialog.Label.HomeFolder=Home folder
 ProjectDialog.Label.MetadataBaseFolder=Metadata base folder 
(HOP_METADATA_FOLDER)
 ProjectDialog.Label.AutoExportMetadata=Auto-export metadata
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectMenuGroupsTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectMenuGroupsTest.java
new file mode 100644
index 0000000000..c3e044453d
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectMenuGroupsTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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
+ *
+ *       https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.junit.jupiter.api.Test;
+
+class ProjectMenuGroupsTest {
+
+  @Test
+  void blankGroupIsUngrouped() {
+    assertFalse(ProjectMenuGroups.isGrouped(null));
+    assertFalse(ProjectMenuGroups.isGrouped(config("plain", null)));
+    assertFalse(ProjectMenuGroups.isGrouped(config("plain", "")));
+    assertFalse(ProjectMenuGroups.isGrouped(config("plain", "   ")));
+    assertTrue(ProjectMenuGroups.isGrouped(config("client", " Clients ")));
+    assertEquals("Clients", ProjectMenuGroups.groupOf(config("client", " 
Clients ")));
+    assertEquals("", ProjectMenuGroups.groupOf(null));
+  }
+
+  @Test
+  void groupsTopicsAndSkipsProjectsWithoutATopic() {
+    List<ProjectConfig> projects = new ArrayList<>();
+    projects.add(config("zeta", "beta"));
+    projects.add(config("alpha", "Alpha"));
+    projects.add(config("mid", " alpha "));
+    projects.add(config("none", "  "));
+    projects.add(config("  ", "Clients"));
+    projects.add(config("Demo", "Clients"));
+    projects.add(config("demo", "Clients"));
+    projects.add(config("other", "clients"));
+    projects.add(null);
+
+    Map<String, List<String>> groups = ProjectMenuGroups.byGroup(projects);
+
+    assertEquals(
+        List.of("Alpha", "alpha", "beta", "Clients", "clients"), new 
ArrayList<>(groups.keySet()));
+    assertEquals(List.of("alpha"), groups.get("Alpha"));
+    assertEquals(List.of("mid"), groups.get("alpha"));
+    assertEquals(List.of("zeta"), groups.get("beta"));
+    assertEquals(List.of("Demo"), groups.get("Clients"));
+    assertEquals(List.of("other"), groups.get("clients"));
+    assertEquals(Map.of(), ProjectMenuGroups.byGroup(null));
+    assertEquals(Map.of(), ProjectMenuGroups.byGroup(List.of()));
+    assertEquals(Map.of(), ProjectMenuGroups.byGroup(List.of(config("solo", 
null))));
+  }
+
+  @Test
+  void namesInsideATopicAreSortedIgnoringCase() {
+    Map<String, List<String>> groups =
+        ProjectMenuGroups.byGroup(
+            List.of(config("b", "Topic"), config("A", "Topic"), config("c", 
"Topic")));
+
+    assertEquals(List.of("A", "b", "c"), groups.get("Topic"));
+  }
+
+  @Test
+  void recentWindowDropsGroupedNamesAndIgnoresNamesPastTheCap() {
+    Map<String, ProjectConfig> byName = new HashMap<>();
+    byName.put("grouped", config("grouped", "Clients"));
+    byName.put("plain", config("plain", null));
+    byName.put("later", config("later", null));
+
+    assertEquals(
+        List.of("missing", "plain"),
+        ProjectMenuGroups.recentUngrouped(
+            List.of("grouped", "missing", "plain", "later"), byName::get, 3));
+    assertEquals(
+        List.of("missing"),
+        ProjectMenuGroups.recentUngrouped(List.of("grouped", "missing", 
"plain"), byName::get, 2));
+    assertEquals(List.of("a"), ProjectMenuGroups.recentUngrouped(List.of("a", 
"b"), null, 1));
+    assertEquals(List.of(), ProjectMenuGroups.recentUngrouped(List.of("", 
"a"), null, 1));
+    assertEquals(List.of("a"), ProjectMenuGroups.recentUngrouped(List.of("", 
"a"), null, 2));
+    assertEquals(List.of(), ProjectMenuGroups.recentUngrouped(null, 
byName::get, 5));
+    assertEquals(List.of(), 
ProjectMenuGroups.recentUngrouped(List.of("plain"), byName::get, 0));
+  }
+
+  private static ProjectConfig config(String name, String group) {
+    ProjectConfig projectConfig = new ProjectConfig(name, "/tmp/" + name, 
"project-config.json");
+    projectConfig.setGroup(group);
+    return projectConfig;
+  }
+}

Reply via email to