From 7bd508997d84001fa2ba5f8713efb2659ccea6d5 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Sun, 18 Oct 2026 12:02:43 +0200 Subject: [PATCH] Fix path sort comparator to be children-first and add test the path sorting indentation requires child paths to follow right after their parent. But this isn't the case when simply sorting by String. added test note: Moved RandomlyFails annotation from test class to test methods, otherwise the testcase would not run. --- .../modules/project/ui/OpenProjectList.java | 49 ++++++++++------- .../project/ui/OpenProjectListTest.java | 53 ++++++++++++++++++- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java b/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java index 982cb07f7156..edf9d6c36387 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java @@ -27,7 +27,6 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.net.URL; -import java.text.Collator; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -122,10 +121,6 @@ public final class OpenProjectList { * otherwise we get a deadlock fairly fast */ static final Mutex MUTEX = new Mutex(); - - public static Comparator projectByPath() { - return new ProjectByPathComparator(); - } // Property names public static final String PROPERTY_OPEN_PROJECTS = "OpenProjects"; @@ -221,6 +216,10 @@ public static void waitProjectsFullyOpen() { getDefault().LOAD.waitFinished(0); } + public static Comparator projectByPath() { + return PROJECT_BY_PATH_COMPARATOR; + } + static void preferredProject(final Project lazyP) { if (lazyP != null) { OPENING_RP.post(new Runnable() { @@ -1922,21 +1921,35 @@ public URL getURL() { } - private static class ProjectByPathComparator implements Comparator { - @Override - public int compare(Project p1, Project p2) { - if (p1 == null && p2 == null) { - return 0; - } - if (p1 == null) { - return -1; - } - if (p2 == null) { - return 1; + /// ensures that child dirs follow right after the parent + private static final Comparator PATH_COMPONENT_COMPARATOR = (p1, p2) -> { + String[] comp1 = p1.split("/"); + String[] comp2 = p2.split("/"); + int min = Math.min(comp1.length, comp2.length); + for (int i = 0; i < min; i++) { + int c = comp1[i].compareTo(comp2[i]); + if (c != 0) { + return c; } - return p1.getProjectDirectory().getPath().compareTo(p2.getProjectDirectory().getPath()); } - } + return Integer.compare(comp1.length, comp2.length); + }; + + private static final Comparator PROJECT_BY_PATH_COMPARATOR = (p1, p2) -> { + if (p1 == null && p2 == null) { + return 0; + } + if (p1 == null) { + return -1; + } + if (p2 == null) { + return 1; + } + return PATH_COMPONENT_COMPARATOR.compare( + p1.getProjectDirectory().getPath(), // String normalized with '/' separator + p2.getProjectDirectory().getPath() + ); + }; private final class NbProjectDeletionListener extends FileChangeAdapter { diff --git a/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/OpenProjectListTest.java b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/OpenProjectListTest.java index ffc848f9956f..dd5f17400032 100644 --- a/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/OpenProjectListTest.java +++ b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/OpenProjectListTest.java @@ -21,6 +21,7 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; +import java.io.File; import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.URL; @@ -29,6 +30,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; @@ -40,6 +42,7 @@ import java.util.logging.Level; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.netbeans.api.project.FileOwnerQuery; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectManager; @@ -48,6 +51,8 @@ import org.netbeans.junit.MockServices; import org.netbeans.junit.NbTestCase; import org.netbeans.junit.RandomlyFails; +import org.netbeans.modules.masterfs.filebasedfs.fileobjects.FolderObj; +import org.netbeans.modules.masterfs.filebasedfs.naming.NamingFactory; import org.netbeans.modules.project.ui.actions.TestSupport; import org.netbeans.spi.project.SubprojectProvider; import org.netbeans.spi.project.ui.ProjectOpenedHook; @@ -56,6 +61,7 @@ import org.openide.filesystems.URLMapper; import org.openide.loaders.DataObject; import org.openide.loaders.DataObjectNotFoundException; +import org.openide.util.Lookup; import org.openide.util.RequestProcessor; import org.openide.util.lookup.Lookups; @@ -63,7 +69,6 @@ * * @author Jiri Rechtacek */ -@RandomlyFails public class OpenProjectListTest extends NbTestCase { FileObject f1_1_open, f1_2_open, f1_3_close; FileObject f2_1_open; @@ -112,6 +117,7 @@ protected Level logLevel() { OpenProjectList.getDefault().close(OpenProjectList.getDefault().getOpenProjects(), false); } + @RandomlyFails public void testOpen () throws Exception { assertTrue ("No project is open.", OpenProjectList.getDefault ().getOpenProjects ().length == 0); CharSequence log = Log.enable("org.netbeans.ui", Level.FINE); @@ -130,6 +136,7 @@ public void testOpen () throws Exception { */ } + @RandomlyFails public void testListenerOpenClose () throws Exception { assertTrue ("No project is open.", OpenProjectList.getDefault ().getOpenProjects ().length == 0); ChangeListener list = new ChangeListener(); @@ -187,6 +194,7 @@ public void testClose () throws Exception { assertTrue ("Document f2_1_open is still loaded.", handler.openFiles.contains (f2_1_open.toURL ().toExternalForm ())); } + @RandomlyFails public void testSerialize() throws Exception { testOpen(); @@ -209,6 +217,7 @@ public void testSerialize() throws Exception { } } + @RandomlyFails public void testOpenDependingProject () throws Exception { assertTrue ("No project is open.", OpenProjectList.getDefault ().getOpenProjects ().length == 0); CharSequence log = Log.enable("org.netbeans.ui", Level.FINE); @@ -226,6 +235,7 @@ public void testOpenDependingProject () throws Exception { assertTrue ("Document f2_1_open is loaded.", handler.openFiles.contains (f2_1_open.toURL ().toExternalForm ())); } + @RandomlyFails public void testCloseProjectWithoutOpenDocuments () throws Exception { assertTrue ("No project is open.", OpenProjectList.getDefault ().getOpenProjects ().length == 0); OpenProjectList.getDefault ().open (project2, false); @@ -242,6 +252,7 @@ public void testCloseProjectWithoutOpenDocuments () throws Exception { assertFalse ("Project2 is closed.", OpenProjectList.getDefault ().isOpen (project2)); } + @RandomlyFails public void testProjectOpenedClosed() throws Exception { ((TestSupport.TestProject) project1).setLookup(Lookups.fixed(new Object[] { new TestProjectOpenedHookImpl(), @@ -263,6 +274,7 @@ public void testProjectOpenedClosed() throws Exception { assertEquals("both close hooks were called", 2, TestProjectOpenedHookImpl.closed); } + @RandomlyFails public void testNotifyDeleted() throws Exception { FileObject workDir = FileUtil.toFileObject (getWorkDir ()); @@ -299,6 +311,7 @@ public void testNotifyDeleted() throws Exception { assertFalse("project2 is not in recent projects list", OpenProjectList.getDefault().getRecentProjects().contains(prj2)); } + @RandomlyFails public void testMainProject() throws Exception { FileObject workDir = FileUtil.toFileObject (getWorkDir ()); @@ -348,6 +361,7 @@ public void testMainProject() throws Exception { OpenProjectList.getDefault().setMainProject(prj1); } + @RandomlyFails public void testProjectClosedRace() throws Exception { final class POHImpl extends ProjectOpenedHook { @@ -427,6 +441,43 @@ public void run() { } + public void testChildrenFirstPathComparator() throws Exception { + // keep relative or this will fail on windows due to File conversion + List paths = Stream.of( + "a/maven", + "a/maven-indexer", + "a/maven-indexer/indexer-core", + "a/maven/maven-model" + ).map(s -> new File(s.replace("/", File.separator))).map(PathProject::new).toList(); + + List sorted = new ArrayList<>(paths); + sorted.sort(OpenProjectList.projectByPath()); + assertEquals( + "maven-indexer must be last", + "[a/maven, a/maven/maven-model, a/maven-indexer, a/maven-indexer/indexer-core]", + sorted.toString() + ); + } + + private record PathProject(File file) implements Project { + + @Override + public FileObject getProjectDirectory() { + return new FolderObj(file, NamingFactory.fromFile(file)); + } + + @Override + public Lookup getLookup() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public String toString() { + return file.toString().replace(File.separator, "/"); + } + + } + // helper code private static class MySubprojectProvider implements SubprojectProvider {