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 f7c92e9e35ed..e6d4f0297078 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/OpenProjectList.java @@ -126,7 +126,11 @@ public final class OpenProjectList { public static Comparator projectByDisplayName() { return new ProjectByDisplayNameComparator(); } - + + static Comparator projectByPath() { + return new ProjectByPathComparator(); + } + // Property names public static final String PROPERTY_OPEN_PROJECTS = "OpenProjects"; public static final String PROPERTY_WILL_OPEN_PROJECTS = "willOpenProjects"; // NOI18N @@ -1965,6 +1969,21 @@ public int compare(Project p1, Project p2) { } } + 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; + } + return p1.getProjectDirectory().getPath().compareTo(p2.getProjectDirectory().getPath()); + } + } private final class NbProjectDeletionListener extends FileChangeAdapter { diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java new file mode 100644 index 000000000000..cf3fc1c672b1 --- /dev/null +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java @@ -0,0 +1,237 @@ +/* + * 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.netbeans.modules.project.ui; + +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import org.netbeans.api.annotations.common.NonNull; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectUtils; +import org.netbeans.api.project.SourceGroup; +import org.netbeans.api.project.Sources; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.Union2; + +/** + * Encapsulation of opened project root keys. Subclass and "connect" to source + * of projects, but overwriting {@link #listProjects()}. + */ +abstract class ProjectsRootKeys { + private final int type; + //@GuardedBy("this") + private final Map projects2Depths = new WeakHashMap<>(); + //@GuardedBy("this") + private final Map > projects2Pairs = new WeakHashMap<>(); + + ProjectsRootKeys(int type) { + this.type = type; + } + + /** The project to process. Called by the internals of this class whenever + * list of projects is needed. + * + * @return non-empty array of projects to create keys for + */ + abstract Project[] listProjects(); + + /** Called when a depth of a project got updated */ + abstract void depthUpdated(PrjInfo info); + + final void update(Project project) { + Reference ref; + synchronized (this) { + ref = projects2Pairs.get(project); + } + if (ref != null) { + var info = ref.get(); + if (info != null) { + info.update(project); + } + } + } + + synchronized final Set clear() { + projects2Pairs.clear(); + return Collections.emptySet(); + } + + Collection getKeys() { + var projects = Arrays.asList(listProjects()); + projects.sort(OpenProjectList.projectByPath()); + + var dirs = new ArrayList(projects.size()); + final java.util.Map snapshot = new HashMap<>(); + var nested = new LinkedList(); + for (Project prj : projects) { + while (!nested.isEmpty()) { + if (FileUtil.isParentOf(nested.peekLast(), prj.getProjectDirectory())) { + break; + } + nested.removeLast(); + } + int originalNestedSize; + int[] nestedArr; + synchronized (this) { + var arr = projects2Depths.get(prj.getProjectDirectory()); + if (arr == null) { + originalNestedSize = -1; + nestedArr = new int[1]; + } else { + originalNestedSize = arr[0]; + nestedArr = arr; + } + } + var nestedSize = nested.size(); + nestedArr[0] = nestedSize; + + var p = new ProjectsRootKeys.PrjInfo(prj, type, nestedArr); + nested.add(prj.getProjectDirectory()); + dirs.add(p); + snapshot.put(prj, p); + synchronized (this) { + projects2Depths.put(prj.getProjectDirectory(), nestedArr); + } + if (originalNestedSize != -1 && originalNestedSize != nestedArr[0]) { + depthUpdated(p); + } + } + synchronized (this) { + projects2Pairs.clear(); + snapshot.entrySet() + .forEach((e) -> projects2Pairs.put( + e.getKey(), + new WeakReference<>(e.getValue()))); + } + return dirs; + } + + int type() { + return type; + } + + PrjInfo createInfo(Project newProj, boolean logicalView) { + int[] depth = this.projects2Depths.get(newProj.getProjectDirectory()); + if (depth == null) { + depth = new int[1]; + } + return new ProjectsRootKeys.PrjInfo( + newProj, + logicalView ? ProjectsRootNode.LOGICAL_VIEW : ProjectsRootNode.PHYSICAL_VIEW, + depth + ); + } + + /** + * Object that comparers two projects just by their directory. This allows + * to replace a LazyProject with real one without discarding the nodes. + */ + static final class PrjInfo extends Object { + final FileObject fo; + private final int type; + private Project project; + private Union2> data; + private final int[] depth; + + private PrjInfo(Project project,int type, int[] depth) { + this.project = project; + this.fo = project.getProjectDirectory(); + this.type = type; + this.depth = depth; + this.data = createData(project, type); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final PrjInfo other = (PrjInfo) obj; + if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { + return false; + } + return true; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); + return hash; + } + + void update(@NonNull final Project project) { + assert project != null; + this.project = project; + this.data = createData(project, type); + } + + Sources getSources() { + return data.second().first(); + } + + SourceGroup[] getSourceGroups() { + return data.second().second(); + } + + LogicalViewProvider getLocalViewProvider() { + return data.hasFirst() ? data.first() : null; + } + + @SuppressWarnings("fallthrough") + private static Union2> createData( + final Project p, + final int type) { + switch (type) { + case ProjectsRootNode.LOGICAL_VIEW: + final LogicalViewProvider lvp = p.getLookup().lookup(LogicalViewProvider.class); + if (lvp != null) { + return Union2.createFirst(lvp); + } + case ProjectsRootNode.PHYSICAL_VIEW: + final Sources s = ProjectUtils.getSources(p); + final SourceGroup[] groups = s.getSourceGroups(Sources.TYPE_GENERIC); + return Union2.createSecond(org.openide.util.Pair.of(s, groups)); + default: + throw new IllegalArgumentException(Integer.toString(type)); + } + } + + final int depth() { + return depth[0]; + } + + final Project project() { + return project; + } + } +} diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java index 1843189d4855..05ae816ec89e 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -35,7 +35,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.WeakHashMap; @@ -85,7 +84,6 @@ import org.openide.util.Mutex; import org.openide.util.NbBundle; import org.openide.util.RequestProcessor; -import org.openide.util.Union2; import org.openide.util.Utilities; import org.openide.util.WeakListeners; import org.openide.util.lookup.Lookups; @@ -160,7 +158,7 @@ Node findNode(FileObject target) { ProjectChildren ch = (ProjectChildren)getChildren(); - assert ((ch.type == LOGICAL_VIEW) || (ch.type == PHYSICAL_VIEW)); + assert ((ch.type() == LOGICAL_VIEW) || (ch.type() == PHYSICAL_VIEW)); // Speed up search in case we have an owner project - look in its node first. Project ownerProject = findProject(target); final SelectInProjectFileOwnerQueryImpl foq = SelectInProjectFileOwnerQueryImpl.getInstance(); @@ -183,7 +181,7 @@ Node findNode(FileObject target) { // ...but it is not clear who has implemented findPath to assume FileObject! n = lvp.findPath(node, target); } - if (n == null && ch.type == PHYSICAL_VIEW) { + if (n == null && ch.type() == PHYSICAL_VIEW) { PhysicalView.PathFinder pf = node.getLookup().lookup(PhysicalView.PathFinder.class); if ( pf != null ) { n = pf.findPath(node, target); @@ -257,24 +255,40 @@ public Node getNode() { // However project rename is currently disabled so it is not a big deal - static class ProjectChildren extends Children.Keys implements ChangeListener, PropertyChangeListener, NodeListener { + static class ProjectChildren extends Children.Keys implements ChangeListener, PropertyChangeListener, NodeListener { static final RequestProcessor RP = new RequestProcessor(ProjectChildren.class); private final java.util.Map > sources2projects = new WeakHashMap>(); - //@GuardedBy("projects2Pairs") - private final java.util.Map > projects2Pairs = Collections.synchronizedMap(new WeakHashMap<>()); - - final int type; + private final ProjectsRootKeys rootKeys; public ProjectChildren( int type ) { - this.type = type; + this.rootKeys = new ProjectsRootKeys(type) { + @Override + Project[] listProjects() { + return OpenProjectList.getDefault().getOpenProjects(); + } + + @Override + void depthUpdated(PrjInfo info) { + for (var n : getNodes()) { + if (n instanceof BadgingNode bn && bn.pair.equals(info)) { + bn.fireDisplayNameChange(); + } + } + } + }; + } + + /** Constructors for running unit tests in isolation */ + ProjectChildren(ProjectsRootKeys keys) { + this.rootKeys = keys; } // Children.Keys impl -------------------------------------------------- @Override - public void addNotify() { + public void addNotify() { OpenProjectList.getDefault().addPropertyChangeListener(this); RP.post(new Runnable() { @Override @@ -291,8 +305,7 @@ public void removeNotify() { sources.removeChangeListener( this ); } sources2projects.clear(); - projects2Pairs.clear(); - setKeys(Collections.emptySet()); + setKeys(rootKeys.clear()); } @Override @@ -312,16 +325,16 @@ public Node[] getNodes(boolean optimalResult) { } @Override - protected Node[] createNodes(Pair p) { - Project project = p.project; + protected Node[] createNodes(ProjectsRootKeys.PrjInfo info) { + Project project = info.project(); Node origNodes[] = null; boolean[] projectInLookup = new boolean[1]; projectInLookup[0] = true; - if (type == PHYSICAL_VIEW) { - final Sources sources = p.data.second().first(); - final SourceGroup[] groups = p.data.second().second(); + if (type() == PHYSICAL_VIEW) { + final Sources sources = info.getSources(); + final SourceGroup[] groups = info.getSourceGroups(); sources.removeChangeListener( this ); sources.addChangeListener( this ); sources2projects.put( sources, new WeakReference( project ) ); @@ -334,27 +347,27 @@ protected Node[] createNodes(Pair p) { } origNodes = nodes.toArray(new Node[0]); } else { - assert type == LOGICAL_VIEW; + assert type() == LOGICAL_VIEW; origNodes = new Node[] { logicalViewForProject( project, - p.data, + info, projectInLookup) }; } Node[] badgedNodes = new Node[ origNodes.length ]; for( int i = 0; i < origNodes.length; i++ ) { - if ( type == PHYSICAL_VIEW && !PhysicalView.isProjectDirNode( origNodes[i] ) ) { + if ( type() == PHYSICAL_VIEW && !PhysicalView.isProjectDirNode( origNodes[i] ) ) { // Don't badge external sources badgedNodes[i] = origNodes[i]; } else { badgedNodes[i] = new BadgingNode( this, - p, + info, origNodes[i], - type == LOGICAL_VIEW + type() == LOGICAL_VIEW ); } } @@ -365,10 +378,11 @@ protected Node[] createNodes(Pair p) { @NonNull final Node logicalViewForProject( @NonNull final Project project, - final Union2> data, + final ProjectsRootKeys.PrjInfo p, final boolean[] projectInLookup) { - Node node; - if (!data.hasFirst()) { + Node node; + var lvp = p.getLocalViewProvider(); + if (lvp == null) { LOG.log( Level.WARNING, "Warning - project of {0} in {1} doesn't supply a LogicalViewProvider in its lookup", // NOI18N @@ -376,8 +390,8 @@ final Node logicalViewForProject( project.getClass(), FileUtil.getFileDisplayName(project.getProjectDirectory()) }); - final Sources sources = data.second().first(); - final SourceGroup[] groups = data.second().second(); + final Sources sources = p.getSources(); + final SourceGroup[] groups = p.getSourceGroups(); sources.removeChangeListener(this); sources.addChangeListener(this); if (groups.length > 0) { @@ -386,7 +400,6 @@ final Node logicalViewForProject( node = Node.EMPTY; } } else { - final LogicalViewProvider lvp = data.first(); node = lvp.createLogicalView(); if (!project.equals(node.getLookup().lookup(Project.class))) { // Various actions, badging, etc. are not going to work. @@ -446,108 +459,25 @@ public void stateChanged( ChangeEvent e ) { // Fix for 50259, callers sometimes hold locks RP.post(new Runnable() { public @Override void run() { - Optional.ofNullable(projects2Pairs.get(project)) - .map((ref) -> ref.get()) - .ifPresent((p) -> p.update(project)); + rootKeys.update(project); refresh(project); } } ); } final void refresh(Project p) { - refreshKey(new Pair(p, type)); + refreshKey(rootKeys.createInfo(p, type() == LOGICAL_VIEW)); } // Own methods --------------------------------------------------------- - public Collection getKeys() { - List projects = Arrays.asList( OpenProjectList.getDefault().getOpenProjects() ); - projects.sort(OpenProjectList.projectByDisplayName()); - - final List dirs = new ArrayList<>(projects.size()); - final java.util.Map snapshot = new HashMap<>(); - for (Project project : projects) { - final Pair p = new Pair(project, type); - dirs.add(p); - snapshot.put(project, p); - } - synchronized (projects2Pairs) { - projects2Pairs.clear(); - snapshot.entrySet() - .forEach((e) -> projects2Pairs.put( - e.getKey(), - new WeakReference<>(e.getValue()))); - - } - return dirs; + public Collection getKeys() { + return this.rootKeys.getKeys(); } - - /** Object that comparers two projects just by their directory. - * This allows to replace a LazyProject with real one without discarding - * the nodes. - */ - static final class Pair extends Object { - Project project; - final FileObject fo; - private final int type; - private Union2> data; - - public Pair( - final Project project, - final int type) { - this.project = project; - this.fo = project.getProjectDirectory(); - this.type = type; - this.data = createData(project, type); - } - - @Override - public boolean equals(Object obj) { - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final Pair other = (Pair) obj; - if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { - return false; - } - return true; - } - - @Override - public int hashCode() { - int hash = 7; - hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); - return hash; - } - - private void update(@NonNull final Project project) { - assert project != null; - this.project = project; - this.data = createData(project, type); - } - private static Union2> createData( - final Project p, - final int type) { - switch (type) { - case LOGICAL_VIEW: - final LogicalViewProvider lvp = p.getLookup().lookup(LogicalViewProvider.class); - if (lvp != null) { - return Union2.createFirst(lvp); - } - case PHYSICAL_VIEW: - final Sources s = ProjectUtils.getSources(p); - final SourceGroup[] groups = s.getSourceGroups(Sources.TYPE_GENERIC); - return Union2.createSecond(org.openide.util.Pair.of(s, groups)); - default: - throw new IllegalArgumentException(Integer.toString(type)); - } - } + private int type() { + return rootKeys.type(); } - } static final class BadgingNode extends FilterNode implements ChangeListener, PropertyChangeListener, Runnable, FileStatusListener { @@ -563,7 +493,7 @@ static final class BadgingNode extends FilterNode implements ChangeListener, Pro private volatile Boolean mainCache; private final ProjectChildren ch; private final boolean logicalView; - private final ProjectChildren.Pair pair; + final ProjectsRootKeys.PrjInfo pair; private final Set projectDirsListenedTo = Collections.newSetFromMap(new WeakHashMap<>()); private static final int DELAY = 50; private final FileChangeListener newSubDirListener = new FileChangeAdapter() { @@ -621,7 +551,7 @@ void init() { } } - public BadgingNode(ProjectChildren ch, ProjectChildren.Pair p, Node n, boolean logicalView) { + public BadgingNode(ProjectChildren ch, ProjectsRootKeys.PrjInfo p, Node n, boolean logicalView) { super(n, null, badgingLookup(n)); this.ch = ch; this.pair = p; @@ -655,7 +585,7 @@ private void replaceProject(Project newProj) { if (newProj == null) { try { newProj = ProjectManager.getDefault().findProject(pair.fo); - if (newProj == pair.project) { + if (newProj == pair.project()) { return; } } catch (IOException | IllegalArgumentException ex) { @@ -685,9 +615,7 @@ private void replaceProject(Project newProj) { if (logicalView) { n = ch.logicalViewForProject( newProj, - ProjectChildren.Pair.createData( - newProj, - logicalView ? LOGICAL_VIEW : PHYSICAL_VIEW), + ch.rootKeys.createInfo(newProj, logicalView), null); OpenProjectList.log(Level.FINER, "logical view {0}", n); } else { @@ -728,7 +656,7 @@ private void replaceProject(Project newProj) { if (newProj == null) { //#228790 use RP instead of EventQueue.invokeLater, job can block on project write mutex RP.post(() -> { - OpenProjectList.getDefault().close(new Project[] { pair.project }, false); + OpenProjectList.getDefault().close(new Project[] { pair.project() }, false); }); } if (OpenProjectList.LOGGER.isLoggable(Level.FINER)) { @@ -825,10 +753,14 @@ public void run() { fireOpenedIconChange(); } if (fireName) { - fireDisplayNameChange(null, null); + fireDisplayNameChange(); } } + private void fireDisplayNameChange() { + fireDisplayNameChange(null, null); + } + @Override public void annotationChanged(FileStatusEvent event) { if (task == null) { @@ -858,6 +790,9 @@ public void annotationChanged(FileStatusEvent event) { LOG.log(Level.INFO, null, e); } } + for (var i = 0; i < pair.depth(); i++) { + original = "\u00BB " + original; + } return original; } @@ -903,8 +838,12 @@ private String toStringForLog() { } catch (FileStateInvalidException e) { LOG.log(Level.INFO, null, e); } - } - return isMainAsync()? "" + htmlName + "" : htmlName; + } + var html = isMainAsync()? "" + htmlName + "" : htmlName; + for (var i = 0; i < pair.depth(); i++) { + html = "» " + html; + } + return html; } public @Override Image getIcon(int type) { @@ -945,7 +884,7 @@ public void propertyChange(PropertyChangeEvent e) { switch (prop) { case OpenProjectList.PROPERTY_MAIN_PROJECT -> { mainCache = null; - fireDisplayNameChange(null, null); + fireDisplayNameChange(); } case OpenProjectList.PROPERTY_REPLACE -> replaceProject((Project)e.getNewValue()); case SourceGroup.PROP_CONTAINERSHIP -> setProjectFilesAsynch(); @@ -961,7 +900,7 @@ private boolean isMainAsync() { @Override public void run() { mainCache = isMain(); - fireDisplayNameChange( null, null ); + fireDisplayNameChange( ); } }); return false; diff --git a/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootKeysTest.java b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootKeysTest.java new file mode 100644 index 000000000000..319414f016ea --- /dev/null +++ b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootKeysTest.java @@ -0,0 +1,127 @@ +/* + * 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.netbeans.modules.project.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNotNull; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectManager; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.project.ui.actions.TestSupport; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.test.MockLookup; + +public class ProjectsRootKeysTest extends NbTestCase { + static final Logger LOG = Logger.getLogger("test.ProjectsRootKeysTest"); + private Project[] projects = new Project[0]; + private ProjectsRootKeys rootKeys; + private TestSupport.TestProject mainPrj1; + private TestSupport.TestProject mainPrj2; + private TestSupport.TestProject nestedPrj1; + private final List depthUpdated = new ArrayList<>(); + + public ProjectsRootKeysTest(String testName) { + super(testName); + } + + @Override + protected Level logLevel() { + return Level.FINER; + } + + @Override + protected void setUp() throws Exception { + this.rootKeys = new ProjectsRootKeys(0) { + @Override + Project[] listProjects() { + return projects; + } + + @Override + void depthUpdated(PrjInfo info) { + depthUpdated.add(info.project()); + } + }; + + MockLookup.setInstances(new TestSupport.TestProjectFactory()); + clearWorkDir(); + FileObject workDir = FileUtil.toFileObject(getWorkDir()); + assertNotNull(workDir); + FileObject prj1 = TestSupport.createTestProject(workDir, "prj1"); + FileObject prj2 = TestSupport.createTestProject(workDir, "prj2"); + FileObject nest1 = TestSupport.createTestProject(prj1, "nested1"); + mainPrj1 = (TestSupport.TestProject) ProjectManager.getDefault().findProject(prj1); + mainPrj2 = (TestSupport.TestProject) ProjectManager.getDefault().findProject(prj2); + nestedPrj1 = (TestSupport.TestProject) ProjectManager.getDefault().findProject(nest1); + assertNotNull("Project found", mainPrj1); + assertNotNull("Project found", mainPrj2); + assertNotNull("Project found", nestedPrj1); + } + + public void testProjectsAreCoLocated() throws Exception { + this.projects = new Project[] { mainPrj1, mainPrj2, nestedPrj1 }; + + var keys = this.rootKeys.getKeys(); + assertEquals("Three keys found: " + keys, 3, keys.size()); + + var it = keys.iterator(); + var k1 = it.next(); + var k2 = it.next(); + var k3 = it.next(); + assertFalse("Iterator is empty", it.hasNext()); + + assertEquals("prj1 comes first", mainPrj1, k1.project()); + assertEquals("then prj1/nested1 is second", nestedPrj1, k2.project()); + assertEquals("prj2 is the last", mainPrj2, k3.project()); + + assertEquals("No depth for prj1", 0, k1.depth()); + assertEquals("Depth one for nested prj", 1, k2.depth()); + assertEquals("No depth for prj2", 0, k3.depth()); + + assertTrue("No depths were updated yet: " + depthUpdated, depthUpdated.isEmpty()); + + // + // now simulate closing of prj1 + // + + this.projects = new Project[] { mainPrj2, nestedPrj1 }; + + it = this.rootKeys.getKeys().iterator(); + + var n1 = it.next(); + var n2 = it.next(); + assertFalse("Iterator is empty", it.hasNext()); + + assertEquals("prj1/nested1 comes first (alphabetically)", nestedPrj1, n1.project()); + assertEquals("prj2 is second", mainPrj2, n2.project()); + + assertEquals("No depth for prj1/nested1 anymore", 0, n1.depth()); + assertEquals("No depth for prj2", 0, n2.depth()); + + assertEquals("No depth in old prj1/nested1 either", 0, k2.depth()); + assertEquals("One project depth updated", 1, depthUpdated.size()); + assertEquals("It is prj/nested1", k2.project(), depthUpdated.get(0)); + depthUpdated.clear(); + } +} diff --git a/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootNodeTest.java b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootNodeTest.java index d47d9c0a6670..05a2d161643c 100644 --- a/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootNodeTest.java +++ b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootNodeTest.java @@ -26,6 +26,7 @@ import java.beans.BeanInfo; import java.beans.PropertyChangeEvent; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; @@ -275,8 +276,18 @@ public void saveProject(Project project) throws IOException, ClassCastException }); Project prj = ProjectManager.getDefault().findProject(root); assertNotNull(prj); + var rootKeys = new ProjectsRootKeys(ProjectsRootNode.LOGICAL_VIEW) { + @Override + Project[] listProjects() { + return new Project[0]; + } + + @Override + void depthUpdated(ProjectsRootKeys.PrjInfo info) { + } + }; System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootNode.ProjectChildren.Pair(prj, ProjectsRootNode.LOGICAL_VIEW), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, rootKeys.createInfo(prj, true), new AbstractNode(Children.LEAF, Lookups.singleton(prj)) { public @Override String getDisplayName() {return "Prj";} public @Override String getHtmlDisplayName() {return "Prj";} @@ -332,6 +343,43 @@ public void saveProject(Project project) throws IOException, ClassCastException assertEquals(new HashSet(Arrays.asList(k1, k2, k3)), fs.badgedFiles); } + public void testNestingVisualizedInBadgeNode() throws Exception { + var root = FileUtil.createMemoryFileSystem().getRoot(); + var nested = root; + var projects = new ArrayList(); + var sampleCount = 10; + for (var i = 0; i < sampleCount; i++) { + var fo = nested.createFolder("prj" + i); + Project prj = new TestProject(fo, null); + projects.add(prj); + nested = fo; + } + System.setProperty("test.nodelay", "true"); + var rootKeys = new ProjectsRootKeys(ProjectsRootNode.LOGICAL_VIEW) { + @Override + Project[] listProjects() { + return projects.toArray(Project[]::new); + } + + @Override + void depthUpdated(ProjectsRootKeys.PrjInfo info) { + } + }; + var ch = new ProjectsRootNode.ProjectChildren(rootKeys); + var nodes = ch.getNodes(true); + assertEquals(sampleCount, nodes.length); + for (var i = 0; i < sampleCount; i++) { + var dn = nodes[i].getDisplayName(); + var hdn = nodes[i].getHtmlDisplayName(); + + var displayNameIndentation = dn.chars().filter(c -> c == 0xbb).count(); + var htmlNameIndentation = hdn.replaceAll("»", "\u00bb").chars().filter(c -> c == 0xbb).count(); + + assertEquals("It is the expected depth: " + dn, i, displayNameIndentation); + assertEquals("Display name indentation and HTML indentation are the same: " + dn + " and " + hdn, displayNameIndentation, htmlNameIndentation); + } + } + public void testIconAnnotated() throws IOException, Exception { final Image icon1 = ImageUtilities.loadImage("org/netbeans/modules/project/ui/resources/icon-1.png"); final Image icon2 = ImageUtilities.loadImage("org/netbeans/modules/project/ui/resources/icon-2.png"); @@ -363,7 +411,17 @@ void disable() { ProjectIconAnnotatorImpl annotator = new ProjectIconAnnotatorImpl(); MockLookup.setInstances(annotator); System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootNode.ProjectChildren.Pair(prj, ProjectsRootNode.LOGICAL_VIEW), + var rootKeys = new ProjectsRootKeys(ProjectsRootNode.LOGICAL_VIEW) { + @Override + Project[] listProjects() { + return new Project[0]; + } + + @Override + void depthUpdated(ProjectsRootKeys.PrjInfo info) { + } + }; + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, rootKeys.createInfo(prj, true), new AbstractNode(Children.LEAF, Lookups.singleton(prj)), true); assertEquals(icon3, node.getIcon(BeanInfo.ICON_COLOR_16x16)); assertEquals(icon2, node.getOpenedIcon(BeanInfo.ICON_COLOR_16x16)); @@ -397,9 +455,20 @@ public void testReplaceProjectSingleNonRootNode() throws Exception { // #197864 public @Override void removeChangeListener(ChangeListener listener) {} })); final LazyProject lp = new LazyProject(d.toURL(), "p", new ExtIcon()); + var rootKeys = new ProjectsRootKeys(ProjectsRootNode.PHYSICAL_VIEW) { + @Override + Project[] listProjects() { + return new Project[0]; + } + + @Override + void depthUpdated(ProjectsRootKeys.PrjInfo info) { + } + }; + Children ch = new ProjectsRootNode.ProjectChildren(ProjectsRootNode.PHYSICAL_VIEW) { public @Override void addNotify() { - setKeys(Collections.singleton(new ProjectsRootNode.ProjectChildren.Pair(lp, ProjectsRootNode.PHYSICAL_VIEW))); + setKeys(Collections.singleton(rootKeys.createInfo(lp, false))); } }; ProjectsRootNode.checkNoLazyNode(ch);