From de9c7500be4d69c2cc9d1df8915d1a90f4a5d46d Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Fri, 4 Sep 2026 15:25:14 +0200 Subject: [PATCH 1/9] Make sure nested projects are co-located --- .../modules/project/ui/OpenProjectList.java | 21 ++++++++++++++++++- .../modules/project/ui/ProjectsRootNode.java | 2 +- 2 files changed, 21 insertions(+), 2 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 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/ProjectsRootNode.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java index 1843189d4855..78fa2a9b3c33 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -462,7 +462,7 @@ final void refresh(Project p) { public Collection getKeys() { List projects = Arrays.asList( OpenProjectList.getDefault().getOpenProjects() ); - projects.sort(OpenProjectList.projectByDisplayName()); + projects.sort(OpenProjectList.projectByPath()); final List dirs = new ArrayList<>(projects.size()); final java.util.Map snapshot = new HashMap<>(); From e8ee14410d3aacb027729f576a8aa8d0f059d753 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Mon, 7 Sep 2026 11:25:14 +0200 Subject: [PATCH 2/9] Prefix the project name with arrow to signal nesting level --- .../modules/project/ui/ProjectsRootNode.java | 37 ++++++++++++++----- .../project/ui/ProjectsRootNodeTest.java | 6 +-- 2 files changed, 31 insertions(+), 12 deletions(-) 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 78fa2a9b3c33..7bb227b43bd7 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -274,7 +275,7 @@ public ProjectChildren( int type ) { // Children.Keys impl -------------------------------------------------- @Override - public void addNotify() { + public void addNotify() { OpenProjectList.getDefault().addPropertyChangeListener(this); RP.post(new Runnable() { @Override @@ -455,7 +456,11 @@ public void stateChanged( ChangeEvent e ) { } final void refresh(Project p) { - refreshKey(new Pair(p, type)); + for (var k : getKeys()) { + if (k.project == p) { + refreshKey(k); + } + } } // Own methods --------------------------------------------------------- @@ -466,10 +471,18 @@ public Collection getKeys() { 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); + var nested = new LinkedList(); + for (Project prj : projects) { + while (!nested.isEmpty()) { + if (FileUtil.isParentOf(nested.peekLast(), prj.getProjectDirectory())) { + break; + } + nested.removeLast(); + } + var p = new Pair(prj, type, nested.size()); + nested.add(prj.getProjectDirectory()); dirs.add(p); - snapshot.put(project, p); + snapshot.put(prj, p); } synchronized (projects2Pairs) { projects2Pairs.clear(); @@ -491,13 +504,15 @@ static final class Pair extends Object { final FileObject fo; private final int type; private Union2> data; + final int depth; public Pair( final Project project, - final int type) { + final int type, int depth) { this.project = project; this.fo = project.getProjectDirectory(); this.type = type; + this.depth = depth; this.data = createData(project, type); } @@ -563,7 +578,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 ProjectChildren.Pair pair; private final Set projectDirsListenedTo = Collections.newSetFromMap(new WeakHashMap<>()); private static final int DELAY = 50; private final FileChangeListener newSubDirListener = new FileChangeAdapter() { @@ -903,8 +918,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) { 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 cc8087f7a9df..5032694996cd 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 @@ -276,7 +276,7 @@ public void saveProject(Project project) throws IOException, ClassCastException Project prj = ProjectManager.getDefault().findProject(root); assertNotNull(prj); 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, new ProjectsRootNode.ProjectChildren.Pair(prj, ProjectsRootNode.LOGICAL_VIEW, 0), new AbstractNode(Children.LEAF, Lookups.singleton(prj)) { public @Override String getDisplayName() {return "Prj";} public @Override String getHtmlDisplayName() {return "Prj";} @@ -363,7 +363,7 @@ 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), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootNode.ProjectChildren.Pair(prj, ProjectsRootNode.LOGICAL_VIEW, 0), new AbstractNode(Children.LEAF, Lookups.singleton(prj)), true); assertEquals(icon3, node.getIcon(BeanInfo.ICON_COLOR_16x16)); assertEquals(icon2, node.getOpenedIcon(BeanInfo.ICON_COLOR_16x16)); @@ -399,7 +399,7 @@ public void testReplaceProjectSingleNonRootNode() throws Exception { // #197864 final LazyProject lp = new LazyProject(d.toURL(), "p", new ExtIcon()); 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(new ProjectsRootNode.ProjectChildren.Pair(lp, ProjectsRootNode.PHYSICAL_VIEW, 0))); } }; ProjectsRootNode.checkNoLazyNode(ch); From 7f014d53136a7f4e1c617c02a09d99b5884cd47c Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 06:35:55 +0200 Subject: [PATCH 3/9] Include depth into equals/hashCode to force refresh of node on change --- .../src/org/netbeans/modules/project/ui/ProjectsRootNode.java | 4 ++++ 1 file changed, 4 insertions(+) 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 7bb227b43bd7..9ee183685ca5 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -528,6 +528,9 @@ public boolean equals(Object obj) { if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { return false; } + if (this.depth != other.depth) { + return false; + } return true; } @@ -535,6 +538,7 @@ public boolean equals(Object obj) { public int hashCode() { int hash = 7; hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); + hash = 7 * hash + this.depth; return hash; } From 7427dcb227433c12ed60d99a1d1cd6ae9f80e5cc Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 08:55:28 +0200 Subject: [PATCH 4/9] Encapsulating PrjInfo and its computation into own class --- .../modules/project/ui/ProjectsRootKeys.java | 198 ++++++++++++++++++ .../modules/project/ui/ProjectsRootNode.java | 168 ++++----------- .../project/ui/ProjectsRootNodeTest.java | 6 +- 3 files changed, 237 insertions(+), 135 deletions(-) create mode 100644 ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java 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..ad610aa28499 --- /dev/null +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java @@ -0,0 +1,198 @@ +/* + * 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 > 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(); + + 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(); + } + var p = new ProjectsRootKeys.PrjInfo(prj, type, nested.size()); + nested.add(prj.getProjectDirectory()); + dirs.add(p); + snapshot.put(prj, p); + } + synchronized (this) { + projects2Pairs.clear(); + snapshot.entrySet() + .forEach((e) -> projects2Pairs.put( + e.getKey(), + new WeakReference<>(e.getValue()))); + + } + return dirs; + } + + int type() { + return type; + } + + /** + * 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 { + Project project; + final FileObject fo; + private final int type; + private Union2> data; + final int depth; + + PrjInfo( + final Project project, + final 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; + } + if (this.depth != other.depth) { + return false; + } + return true; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); + hash = 7 * hash + this.depth; + 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)); + } + } + } +} 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 9ee183685ca5..49273479f343 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -161,7 +161,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(); @@ -184,7 +184,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); @@ -258,18 +258,20 @@ 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(); + } + }; } // Children.Keys impl -------------------------------------------------- @@ -292,8 +294,7 @@ public void removeNotify() { sources.removeChangeListener( this ); } sources2projects.clear(); - projects2Pairs.clear(); - setKeys(Collections.emptySet()); + setKeys(rootKeys.clear()); } @Override @@ -313,16 +314,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 ) ); @@ -335,27 +336,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 ); } } @@ -366,10 +367,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 @@ -377,8 +379,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) { @@ -387,7 +389,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. @@ -447,9 +448,7 @@ 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); } } ); @@ -465,108 +464,13 @@ final void refresh(Project p) { // Own methods --------------------------------------------------------- - public Collection getKeys() { - List projects = Arrays.asList( OpenProjectList.getDefault().getOpenProjects() ); - projects.sort(OpenProjectList.projectByPath()); - - final List 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(); - } - var p = new Pair(prj, type, nested.size()); - nested.add(prj.getProjectDirectory()); - dirs.add(p); - snapshot.put(prj, 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; - final int depth; - - public Pair( - final Project project, - final 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 Pair other = (Pair) obj; - if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { - return false; - } - if (this.depth != other.depth) { - return false; - } - return true; - } - - @Override - public int hashCode() { - int hash = 7; - hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); - hash = 7 * hash + this.depth; - 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 { @@ -582,7 +486,7 @@ static final class BadgingNode extends FilterNode implements ChangeListener, Pro private volatile Boolean mainCache; private final ProjectChildren ch; private final boolean logicalView; - 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() { @@ -640,7 +544,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; @@ -704,9 +608,9 @@ private void replaceProject(Project newProj) { if (logicalView) { n = ch.logicalViewForProject( newProj, - ProjectChildren.Pair.createData( + new ProjectsRootKeys.PrjInfo( newProj, - logicalView ? LOGICAL_VIEW : PHYSICAL_VIEW), + logicalView ? LOGICAL_VIEW : PHYSICAL_VIEW, 0), null); OpenProjectList.log(Level.FINER, "logical view {0}", n); } else { 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 5032694996cd..37b35471da69 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 @@ -276,7 +276,7 @@ public void saveProject(Project project) throws IOException, ClassCastException Project prj = ProjectManager.getDefault().findProject(root); assertNotNull(prj); System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootNode.ProjectChildren.Pair(prj, ProjectsRootNode.LOGICAL_VIEW, 0), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootKeys.PrjInfo(prj, ProjectsRootNode.LOGICAL_VIEW, 0), new AbstractNode(Children.LEAF, Lookups.singleton(prj)) { public @Override String getDisplayName() {return "Prj";} public @Override String getHtmlDisplayName() {return "Prj";} @@ -363,7 +363,7 @@ 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, 0), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootKeys.PrjInfo(prj, ProjectsRootNode.LOGICAL_VIEW, 0), new AbstractNode(Children.LEAF, Lookups.singleton(prj)), true); assertEquals(icon3, node.getIcon(BeanInfo.ICON_COLOR_16x16)); assertEquals(icon2, node.getOpenedIcon(BeanInfo.ICON_COLOR_16x16)); @@ -399,7 +399,7 @@ public void testReplaceProjectSingleNonRootNode() throws Exception { // #197864 final LazyProject lp = new LazyProject(d.toURL(), "p", new ExtIcon()); Children ch = new ProjectsRootNode.ProjectChildren(ProjectsRootNode.PHYSICAL_VIEW) { public @Override void addNotify() { - setKeys(Collections.singleton(new ProjectsRootNode.ProjectChildren.Pair(lp, ProjectsRootNode.PHYSICAL_VIEW, 0))); + setKeys(Collections.singleton(new ProjectsRootKeys.PrjInfo(lp, ProjectsRootNode.PHYSICAL_VIEW, 0))); } }; ProjectsRootNode.checkNoLazyNode(ch); From 732e99d2e7ca6b2915093e32341f641ef7641e9e Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 09:35:30 +0200 Subject: [PATCH 5/9] Unit testing co-location and depth of a project --- .../project/ui/ProjectsRootKeysTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootKeysTest.java 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..e55a4de9a956 --- /dev/null +++ b/ide/projectui/test/unit/src/org/netbeans/modules/project/ui/ProjectsRootKeysTest.java @@ -0,0 +1,116 @@ +/* + * 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.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; + +/** + * + * @author devel + */ +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; + + 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; + } + }; + + 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); + + // + // 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); + } +} From ade3ec568bc4826019520d8a3d2af1a598208d16 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 10:21:38 +0200 Subject: [PATCH 6/9] Notify depth updated to re-render HTML display name of bagging nodes --- .../modules/project/ui/ProjectsRootKeys.java | 57 +++++++++++++++---- .../modules/project/ui/ProjectsRootNode.java | 36 +++++++----- .../project/ui/ProjectsRootKeysTest.java | 39 ++++++++----- .../project/ui/ProjectsRootNodeTest.java | 6 +- 4 files changed, 96 insertions(+), 42 deletions(-) diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java index ad610aa28499..5fa86eb43c7b 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java @@ -46,6 +46,8 @@ 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) { @@ -59,6 +61,9 @@ abstract class ProjectsRootKeys { */ 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) { @@ -91,10 +96,31 @@ Collection getKeys() { } nested.removeLast(); } - var p = new ProjectsRootKeys.PrjInfo(prj, type, nested.size()); + int originalNestedSize; + int[] nestedArr; + synchronized (this) { + var arr = projects2Depths.get(prj); + 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, nestedArr); + } + if (originalNestedSize != -1 && originalNestedSize != nestedArr[0]) { + depthUpdated(p); + } } synchronized (this) { projects2Pairs.clear(); @@ -102,7 +128,6 @@ Collection getKeys() { .forEach((e) -> projects2Pairs.put( e.getKey(), new WeakReference<>(e.getValue()))); - } return dirs; } @@ -111,20 +136,26 @@ int type() { return type; } + static PrjInfo createInfo(Project newProj, boolean logicalView) { + return new ProjectsRootKeys.PrjInfo( + newProj, + logicalView ? ProjectsRootNode.LOGICAL_VIEW : ProjectsRootNode.PHYSICAL_VIEW, + new int[1] + ); + } + /** * 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 { - Project project; final FileObject fo; private final int type; + private Project project; private Union2> data; - final int depth; + private final int[] depth; - PrjInfo( - final Project project, - final int type, int depth) { + private PrjInfo(Project project,int type, int[] depth) { this.project = project; this.fo = project.getProjectDirectory(); this.type = type; @@ -144,9 +175,6 @@ public boolean equals(Object obj) { if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { return false; } - if (this.depth != other.depth) { - return false; - } return true; } @@ -154,7 +182,6 @@ public boolean equals(Object obj) { public int hashCode() { int hash = 7; hash = 53 * hash + (this.fo != null ? this.fo.hashCode() : 0); - hash = 7 * hash + this.depth; return hash; } @@ -194,5 +221,13 @@ private static Union2 { - OpenProjectList.getDefault().close(new Project[] { pair.project }, false); + OpenProjectList.getDefault().close(new Project[] { pair.project() }, false); }); } if (OpenProjectList.LOGGER.isLoggable(Level.FINER)) { @@ -748,10 +752,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) { @@ -828,7 +836,7 @@ private String toStringForLog() { } } var html = isMainAsync()? "" + htmlName + "" : htmlName; - for (var i = 0; i < pair.depth; i++) { + for (var i = 0; i < pair.depth(); i++) { html = "» " + html; } return html; @@ -872,7 +880,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(); @@ -888,7 +896,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 index e55a4de9a956..319414f016ea 100644 --- 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 @@ -18,6 +18,8 @@ */ 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; @@ -30,10 +32,6 @@ import org.openide.filesystems.FileUtil; import org.openide.util.test.MockLookup; -/** - * - * @author devel - */ public class ProjectsRootKeysTest extends NbTestCase { static final Logger LOG = Logger.getLogger("test.ProjectsRootKeysTest"); private Project[] projects = new Project[0]; @@ -41,6 +39,7 @@ public class ProjectsRootKeysTest extends NbTestCase { private TestSupport.TestProject mainPrj1; private TestSupport.TestProject mainPrj2; private TestSupport.TestProject nestedPrj1; + private final List depthUpdated = new ArrayList<>(); public ProjectsRootKeysTest(String testName) { super(testName); @@ -58,6 +57,11 @@ protected void setUp() throws Exception { Project[] listProjects() { return projects; } + + @Override + void depthUpdated(PrjInfo info) { + depthUpdated.add(info.project()); + } }; MockLookup.setInstances(new TestSupport.TestProjectFactory()); @@ -87,13 +91,15 @@ public void testProjectsAreCoLocated() throws Exception { 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("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()); - 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 @@ -107,10 +113,15 @@ public void testProjectsAreCoLocated() throws Exception { 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("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 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 37b35471da69..3d4ac5bc056f 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 @@ -276,7 +276,7 @@ public void saveProject(Project project) throws IOException, ClassCastException Project prj = ProjectManager.getDefault().findProject(root); assertNotNull(prj); System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootKeys.PrjInfo(prj, ProjectsRootNode.LOGICAL_VIEW, 0), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, ProjectsRootKeys.createInfo(prj, true), new AbstractNode(Children.LEAF, Lookups.singleton(prj)) { public @Override String getDisplayName() {return "Prj";} public @Override String getHtmlDisplayName() {return "Prj";} @@ -363,7 +363,7 @@ void disable() { ProjectIconAnnotatorImpl annotator = new ProjectIconAnnotatorImpl(); MockLookup.setInstances(annotator); System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, new ProjectsRootKeys.PrjInfo(prj, ProjectsRootNode.LOGICAL_VIEW, 0), + ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, ProjectsRootKeys.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)); @@ -399,7 +399,7 @@ public void testReplaceProjectSingleNonRootNode() throws Exception { // #197864 final LazyProject lp = new LazyProject(d.toURL(), "p", new ExtIcon()); Children ch = new ProjectsRootNode.ProjectChildren(ProjectsRootNode.PHYSICAL_VIEW) { public @Override void addNotify() { - setKeys(Collections.singleton(new ProjectsRootKeys.PrjInfo(lp, ProjectsRootNode.PHYSICAL_VIEW, 0))); + setKeys(Collections.singleton(ProjectsRootKeys.createInfo(lp, false))); } }; ProjectsRootNode.checkNoLazyNode(ch); From 3b438555b3f76bccc268316a3335cafe22c2d7b5 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 20:26:44 +0200 Subject: [PATCH 7/9] Reuse depth info on refresh --- .../modules/project/ui/ProjectsRootKeys.java | 8 +++- .../modules/project/ui/ProjectsRootNode.java | 8 +--- .../project/ui/ProjectsRootNodeTest.java | 37 +++++++++++++++++-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java index 5fa86eb43c7b..fa0e7dda8103 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java @@ -136,11 +136,15 @@ int type() { return type; } - static PrjInfo createInfo(Project newProj, boolean logicalView) { + PrjInfo createInfo(Project newProj, boolean logicalView) { + int[] depth = this.projects2Depths.get(newProj); + if (depth == null) { + depth = new int[1]; + } return new ProjectsRootKeys.PrjInfo( newProj, logicalView ? ProjectsRootNode.LOGICAL_VIEW : ProjectsRootNode.PHYSICAL_VIEW, - new int[1] + depth ); } 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 bcc60c3241d3..1b69fc16f34f 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -461,11 +461,7 @@ public void stateChanged( ChangeEvent e ) { } final void refresh(Project p) { - for (var k : getKeys()) { - if (k.project() == p) { - refreshKey(k); - } - } + refreshKey(rootKeys.createInfo(p, type() == LOGICAL_VIEW)); } // Own methods --------------------------------------------------------- @@ -614,7 +610,7 @@ private void replaceProject(Project newProj) { if (logicalView) { n = ch.logicalViewForProject( newProj, - ProjectsRootKeys.createInfo(newProj, logicalView), + ch.rootKeys.createInfo(newProj, logicalView), null); OpenProjectList.log(Level.FINER, "logical view {0}", n); } else { 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 1d855d83ef2a..73c83025efa4 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 @@ -275,8 +275,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, ProjectsRootKeys.createInfo(prj, true), + 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";} @@ -363,7 +373,17 @@ void disable() { ProjectIconAnnotatorImpl annotator = new ProjectIconAnnotatorImpl(); MockLookup.setInstances(annotator); System.setProperty("test.nodelay", "true"); - ProjectsRootNode.BadgingNode node = new ProjectsRootNode.BadgingNode(null, ProjectsRootKeys.createInfo(prj, true), + 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 +417,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(ProjectsRootKeys.createInfo(lp, false))); + setKeys(Collections.singleton(rootKeys.createInfo(lp, false))); } }; ProjectsRootNode.checkNoLazyNode(ch); From 09c5c88d65e7c943a3d33d33d6400bfc9f920b02 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 8 Sep 2026 20:32:20 +0200 Subject: [PATCH 8/9] Let depth be associated with project folder, not project instance --- .../org/netbeans/modules/project/ui/ProjectsRootKeys.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java index fa0e7dda8103..cf3fc1c672b1 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootKeys.java @@ -46,7 +46,7 @@ abstract class ProjectsRootKeys { private final int type; //@GuardedBy("this") - private final Map projects2Depths = new WeakHashMap<>(); + private final Map projects2Depths = new WeakHashMap<>(); //@GuardedBy("this") private final Map > projects2Pairs = new WeakHashMap<>(); @@ -99,7 +99,7 @@ Collection getKeys() { int originalNestedSize; int[] nestedArr; synchronized (this) { - var arr = projects2Depths.get(prj); + var arr = projects2Depths.get(prj.getProjectDirectory()); if (arr == null) { originalNestedSize = -1; nestedArr = new int[1]; @@ -116,7 +116,7 @@ Collection getKeys() { dirs.add(p); snapshot.put(prj, p); synchronized (this) { - projects2Depths.put(prj, nestedArr); + projects2Depths.put(prj.getProjectDirectory(), nestedArr); } if (originalNestedSize != -1 && originalNestedSize != nestedArr[0]) { depthUpdated(p); @@ -137,7 +137,7 @@ int type() { } PrjInfo createInfo(Project newProj, boolean logicalView) { - int[] depth = this.projects2Depths.get(newProj); + int[] depth = this.projects2Depths.get(newProj.getProjectDirectory()); if (depth == null) { depth = new int[1]; } From 0ac9a489d80a527c26ca4ec1c60f46bbd0854309 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Wed, 9 Sep 2026 10:44:17 +0200 Subject: [PATCH 9/9] Make sure display name is also indented --- .../modules/project/ui/ProjectsRootNode.java | 8 ++++ .../project/ui/ProjectsRootNodeTest.java | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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 1b69fc16f34f..05ae816ec89e 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -279,6 +279,11 @@ void depthUpdated(PrjInfo info) { } }; } + + /** Constructors for running unit tests in isolation */ + ProjectChildren(ProjectsRootKeys keys) { + this.rootKeys = keys; + } // Children.Keys impl -------------------------------------------------- @@ -785,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; } 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 73c83025efa4..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; @@ -342,6 +343,43 @@ void depthUpdated(ProjectsRootKeys.PrjInfo info) { 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");