From de9c7500be4d69c2cc9d1df8915d1a90f4a5d46d Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Fri, 4 Sep 2026 15:25:14 +0200 Subject: [PATCH 1/3] 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 2130f7fac27e2d37b9e467f070f62d76c0cef80c Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Fri, 4 Sep 2026 16:29:24 +0200 Subject: [PATCH 2/3] Indent project view by nested hierarchy of projects --- ide/projectui/nbproject/project.xml | 2 +- .../modules/project/ui/ProjectTab.java | 36 +++++++++++++++++++ .../modules/project/ui/ProjectsRootNode.java | 29 +++++++++++---- .../project/ui/ProjectsRootNodeTest.java | 6 ++-- platform/openide.explorer/apichanges.xml | 16 ++++++++- platform/openide.explorer/manifest.mf | 2 +- .../openide/explorer/view/NodeListModel.java | 3 +- .../openide/explorer/view/NodeRenderer.java | 27 ++++++++++++-- 8 files changed, 104 insertions(+), 17 deletions(-) diff --git a/ide/projectui/nbproject/project.xml b/ide/projectui/nbproject/project.xml index 6fe03beedfb3..bff80d466546 100644 --- a/ide/projectui/nbproject/project.xml +++ b/ide/projectui/nbproject/project.xml @@ -169,7 +169,7 @@ - 6.8 + 6.97 diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectTab.java b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectTab.java index 542bf3d245c7..5c2bc81a18f3 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectTab.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectTab.java @@ -57,8 +57,10 @@ import javax.swing.ActionMap; import javax.swing.BorderFactory; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JPopupMenu; +import javax.swing.JTree; import javax.swing.KeyStroke; import javax.swing.SwingConstants; import javax.swing.SwingUtilities; @@ -67,6 +69,7 @@ import javax.swing.event.ChangeListener; import javax.swing.text.DefaultEditorKit; import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreeCellRenderer; import javax.swing.tree.TreeModel; import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; @@ -89,6 +92,7 @@ import org.openide.explorer.ExplorerManager; import org.openide.explorer.ExplorerUtils; import org.openide.explorer.view.BeanTreeView; +import org.openide.explorer.view.NodeRenderer; import org.openide.explorer.view.Visualizer; import org.openide.filesystems.FileObject; import org.openide.loaders.DataObject; @@ -417,6 +421,34 @@ public void writeExternal (ObjectOutput out) throws IOException { } } + private static class DepthRespectingRenderer extends NodeRenderer { + DepthRespectingRenderer() { + } + + @Override + protected int findIndent(Object model, TreeNode vis) { + var node = Visualizer.findNode(vis); + while (node != null) { + if (node instanceof ProjectsRootNode.BadgingNode) { + var badge = (ProjectsRootNode.BadgingNode) node; + return badge.pair.depth; + } + node = node.getParentNode(); + } + return 0; + } + + private static final int parents(FileObject fo) { + int cnt = 0; + while (fo != null) { + cnt++; + fo = fo.getParent(); + } + return cnt; + } + + } + private class KeepExpansion implements Runnable { final RequestProcessor.Task task; final List exPaths; @@ -719,6 +751,10 @@ private void restoreTreeView() { /** Extending bean treeview. To be able to persist the selected paths */ private class ProjectTreeView extends BeanTreeView { + { + this.tree.setCellRenderer(new DepthRespectingRenderer()); + } + public void scrollToNode(final Node n) { // has to be delayed to be sure that events for Visualizers // were processed and TreeNodes are already in hierarchy 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..2a6a2d6edb11 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() { 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); diff --git a/platform/openide.explorer/apichanges.xml b/platform/openide.explorer/apichanges.xml index 313ad2d22ce2..d69523c37867 100644 --- a/platform/openide.explorer/apichanges.xml +++ b/platform/openide.explorer/apichanges.xml @@ -19,13 +19,27 @@ under the License. --> - + Explorer API + + + NodeRenderer.findIndent + + + + + + Control NodeRenderer indentation by overriding its + findIndent method. + + + + Expose scrollOnExpand property on TreeView. diff --git a/platform/openide.explorer/manifest.mf b/platform/openide.explorer/manifest.mf index e09849bddf66..125e28ef483b 100644 --- a/platform/openide.explorer/manifest.mf +++ b/platform/openide.explorer/manifest.mf @@ -2,5 +2,5 @@ Manifest-Version: 1.0 OpenIDE-Module: org.openide.explorer OpenIDE-Module-Localizing-Bundle: org/openide/explorer/Bundle.properties AutoUpdate-Essential-Module: true -OpenIDE-Module-Specification-Version: 6.96 +OpenIDE-Module-Specification-Version: 6.97 diff --git a/platform/openide.explorer/src/org/openide/explorer/view/NodeListModel.java b/platform/openide.explorer/src/org/openide/explorer/view/NodeListModel.java index c37cb29b483b..841459a96228 100644 --- a/platform/openide.explorer/src/org/openide/explorer/view/NodeListModel.java +++ b/platform/openide.explorer/src/org/openide/explorer/view/NodeListModel.java @@ -28,6 +28,7 @@ import java.util.*; import javax.swing.*; +import javax.swing.tree.TreeNode; import org.openide.nodes.Children; @@ -295,7 +296,7 @@ private VisualizerNode findElementAt(VisualizerNode vis, boolean countSelf, int * @param o the visualizer node * @return depth or 0 if not found */ - static int findVisualizerDepth(ListModel m, VisualizerNode o) { + static int findVisualizerDepth(ListModel m, TreeNode o) { if (m instanceof NodeListModel) { NodeListModel n = (NodeListModel) m; Info i = n.childrenCount.get(o); diff --git a/platform/openide.explorer/src/org/openide/explorer/view/NodeRenderer.java b/platform/openide.explorer/src/org/openide/explorer/view/NodeRenderer.java index b487742fa528..48470658650b 100644 --- a/platform/openide.explorer/src/org/openide/explorer/view/NodeRenderer.java +++ b/platform/openide.explorer/src/org/openide/explorer/view/NodeRenderer.java @@ -29,6 +29,8 @@ import javax.swing.*; import javax.swing.tree.TreeCellRenderer; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreeNode; import org.openide.util.Exceptions; import org.openide.util.NbBundle; @@ -135,7 +137,9 @@ public Component getTreeCellRendererComponent( //Do our additional configuration - set up the icon and possibly //do some hacks to make it look focused for TreeTableView - configureFrom(renderer, tree, expanded, sel, vis); + int iconWidth = configureFrom(renderer, tree, expanded, sel, vis); + + renderer.setIndent(iconWidth * findIndent(tree.getModel(), vis)); return result; } @@ -156,7 +160,7 @@ public Component getListCellRendererComponent( String text = vis.getHtmlDisplayName(); if (list.getModel() instanceof NodeListModel) { - int depth = NodeListModel.findVisualizerDepth(list.getModel(), vis); + int depth = findIndent(list, vis); if (depth == -1) { text = NbBundle.getMessage(NodeRenderer.class, "LBL_UP"); } @@ -187,7 +191,7 @@ public Component getListCellRendererComponent( //in the node tree. Only does anything if you've subclassed and //overridden createModel(). Does anybody do that? if (list.getModel() instanceof NodeListModel && (((NodeListModel) list.getModel()).getDepth() > 1)) { - int indent = iconWidth * NodeListModel.findVisualizerDepth(list.getModel(), vis); + int indent = iconWidth * findIndent(list.getModel(), vis); renderer.setIndent(indent); } @@ -196,6 +200,23 @@ public Component getListCellRendererComponent( return result; } + /** Finds the indentation level of a node. + * + * @param model the model (either {@link ListModel} or {@link TreeModel}) that's currently being rendered + * @param visualizer the visualizer to find indentation for + * @return the indentation level for the renderer of the given {@code visualizer}; + * return {@code 0} for no indentation or positive number to indent the rendering to right + * @see Visualizer#findNode(java.lang.Object) + * @sincet 6.97 + */ + protected int findIndent(Object model, TreeNode visualizer) { + if (model instanceof ListModel) { + return NodeListModel.findVisualizerDepth((ListModel) model, visualizer); + } else { + return 0; + } + } + /** Utility method which performs configuration which is common to all of the renderer * implementations - sets the icon and focus properties on the renderer * from the VisualizerNode. From 0a28da065342850d0d9e0db65883fdb8501122a8 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Sat, 5 Sep 2026 04:25:40 +0200 Subject: [PATCH 3/3] Equals and hash need to count with depth to update view when projects are reindented due to open/close --- .../modules/project/ui/ProjectsRootNode.java | 22 +++++++++++-------- .../project/ui/ProjectsRootNodeTest.java | 10 +++++++++ 2 files changed, 23 insertions(+), 9 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 2a6a2d6edb11..a63b0c5a8974 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java +++ b/ide/projectui/src/org/netbeans/modules/project/ui/ProjectsRootNode.java @@ -36,6 +36,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; @@ -516,8 +517,18 @@ public Pair( this.data = createData(project, type); } + @Override + public int hashCode() { + int hash = 7 * this.depth; + hash += 53 * this.fo.hashCode(); + return hash; + } + @Override public boolean equals(Object obj) { + if (this == obj) { + return true; + } if (obj == null) { return false; } @@ -525,17 +536,10 @@ public boolean equals(Object obj) { return false; } final Pair other = (Pair) obj; - if (this.fo != other.fo && (this.fo == null || !this.fo.equals(other.fo))) { + 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); - return hash; + return Objects.equals(this.fo, other.fo); } private void update(@NonNull final Project project) { 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..b2562ea47fbe 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 @@ -32,6 +32,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import javax.swing.event.ChangeListener; +import static org.junit.Assert.assertNotEquals; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectManager; import org.netbeans.api.project.SourceGroup; @@ -408,4 +409,13 @@ public void testReplaceProjectSingleNonRootNode() throws Exception { // #197864 assertEquals("p - Testing", ns[0].getDisplayName()); } + public void testDepthIsImportantForEqualsAndHash() throws Exception { + var d = FileUtil.toFileObject(getWorkDir()).createFolder("p"); + var lp = new LazyProject(d.toURL(), "p", new ExtIcon()); + var p1 = new ProjectsRootNode.ProjectChildren.Pair(lp, ProjectsRootNode.PHYSICAL_VIEW, 1); + var p2 = new ProjectsRootNode.ProjectChildren.Pair(lp, ProjectsRootNode.PHYSICAL_VIEW, 2); + + assertNotEquals("Depth is important for equals", p1, p2); + assertNotEquals("Depth is important for hash (unless accidentally equal)", p1.hashCode(), p2.hashCode()); + } }