From f81a904b8ba4c3847d30fc762d98614381f45bf5 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 00:11:16 +0200 Subject: [PATCH 01/15] Seed NB repo with generic-project marker files - each cluster becomes a generic project so that it can be opened - .github receives one too --- .github/.nbgp | 1 + .nbgp | 1 + apisupport/.nbgp | 1 + contrib/.nbgp | 1 + cpplite/.nbgp | 1 + enterprise/.nbgp | 1 + ergonomics/.nbgp | 1 + extide/.nbgp | 1 + extra/.nbgp | 1 + groovy/.nbgp | 1 + harness/.nbgp | 1 + ide/.nbgp | 1 + java/.nbgp | 1 + javafx/.nbgp | 1 + nb/.nbgp | 1 + php/.nbgp | 1 + platform/.nbgp | 1 + profiler/.nbgp | 1 + rust/.nbgp | 1 + webcommon/.nbgp | 1 + websvccommon/.nbgp | 1 + 21 files changed, 21 insertions(+) create mode 100644 .github/.nbgp create mode 100644 .nbgp create mode 100644 apisupport/.nbgp create mode 100644 contrib/.nbgp create mode 100644 cpplite/.nbgp create mode 100644 enterprise/.nbgp create mode 100644 ergonomics/.nbgp create mode 100644 extide/.nbgp create mode 100644 extra/.nbgp create mode 100644 groovy/.nbgp create mode 100644 harness/.nbgp create mode 100644 ide/.nbgp create mode 100644 java/.nbgp create mode 100644 javafx/.nbgp create mode 100644 nb/.nbgp create mode 100644 php/.nbgp create mode 100644 platform/.nbgp create mode 100644 profiler/.nbgp create mode 100644 rust/.nbgp create mode 100644 webcommon/.nbgp create mode 100644 websvccommon/.nbgp diff --git a/.github/.nbgp b/.github/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/.github/.nbgp @@ -0,0 +1 @@ + diff --git a/.nbgp b/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/.nbgp @@ -0,0 +1 @@ + diff --git a/apisupport/.nbgp b/apisupport/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/apisupport/.nbgp @@ -0,0 +1 @@ + diff --git a/contrib/.nbgp b/contrib/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/contrib/.nbgp @@ -0,0 +1 @@ + diff --git a/cpplite/.nbgp b/cpplite/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/cpplite/.nbgp @@ -0,0 +1 @@ + diff --git a/enterprise/.nbgp b/enterprise/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/enterprise/.nbgp @@ -0,0 +1 @@ + diff --git a/ergonomics/.nbgp b/ergonomics/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ergonomics/.nbgp @@ -0,0 +1 @@ + diff --git a/extide/.nbgp b/extide/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/extide/.nbgp @@ -0,0 +1 @@ + diff --git a/extra/.nbgp b/extra/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/extra/.nbgp @@ -0,0 +1 @@ + diff --git a/groovy/.nbgp b/groovy/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/groovy/.nbgp @@ -0,0 +1 @@ + diff --git a/harness/.nbgp b/harness/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/harness/.nbgp @@ -0,0 +1 @@ + diff --git a/ide/.nbgp b/ide/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ide/.nbgp @@ -0,0 +1 @@ + diff --git a/java/.nbgp b/java/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/java/.nbgp @@ -0,0 +1 @@ + diff --git a/javafx/.nbgp b/javafx/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/javafx/.nbgp @@ -0,0 +1 @@ + diff --git a/nb/.nbgp b/nb/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/nb/.nbgp @@ -0,0 +1 @@ + diff --git a/php/.nbgp b/php/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/php/.nbgp @@ -0,0 +1 @@ + diff --git a/platform/.nbgp b/platform/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/platform/.nbgp @@ -0,0 +1 @@ + diff --git a/profiler/.nbgp b/profiler/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/profiler/.nbgp @@ -0,0 +1 @@ + diff --git a/rust/.nbgp b/rust/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/rust/.nbgp @@ -0,0 +1 @@ + diff --git a/webcommon/.nbgp b/webcommon/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/webcommon/.nbgp @@ -0,0 +1 @@ + diff --git a/websvccommon/.nbgp b/websvccommon/.nbgp new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/websvccommon/.nbgp @@ -0,0 +1 @@ + From 1711c98b8ce9c8d81735775cd4fd5817a36194d9 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 00:05:59 +0200 Subject: [PATCH 02/15] initial gp impl --- ide/generic.project/build.xml | 25 ++++ ide/generic.project/manifest.mf | 4 + .../nbproject/project.properties | 19 +++ ide/generic.project/nbproject/project.xml | 135 ++++++++++++++++++ .../modules/generic/project/Bundle.properties | 19 +++ .../generic/project/GenericProject.java | 135 ++++++++++++++++++ .../GenericProjectLogicalViewProvider.java | 77 ++++++++++ .../netbeans/modules/generic/project/gp.png | Bin 0 -> 694 bytes .../netbeans/modules/generic/project/gp.svg | 59 ++++++++ .../netbeans/modules/generic/project/gp24.png | Bin 0 -> 911 bytes .../netbeans/modules/generic/project/gp24.svg | 68 +++++++++ .../generic/project/GenericProjectTest.java | 77 ++++++++++ nbbuild/cluster.properties | 1 + 13 files changed, 619 insertions(+) create mode 100644 ide/generic.project/build.xml create mode 100644 ide/generic.project/manifest.mf create mode 100644 ide/generic.project/nbproject/project.properties create mode 100644 ide/generic.project/nbproject/project.xml create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/Bundle.properties create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/gp.png create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/gp.svg create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/gp24.png create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/gp24.svg create mode 100644 ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java diff --git a/ide/generic.project/build.xml b/ide/generic.project/build.xml new file mode 100644 index 000000000000..24ae57c89b81 --- /dev/null +++ b/ide/generic.project/build.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/ide/generic.project/manifest.mf b/ide/generic.project/manifest.mf new file mode 100644 index 000000000000..177f6e972319 --- /dev/null +++ b/ide/generic.project/manifest.mf @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +OpenIDE-Module: org.netbeans.modules.generic.project +OpenIDE-Module-Localizing-Bundle: org/netbeans/modules/generic/project/Bundle.properties +OpenIDE-Module-Specification-Version: 1.0 \ No newline at end of file diff --git a/ide/generic.project/nbproject/project.properties b/ide/generic.project/nbproject/project.properties new file mode 100644 index 000000000000..4112e4ee792e --- /dev/null +++ b/ide/generic.project/nbproject/project.properties @@ -0,0 +1,19 @@ +# 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. + +javac.compilerargs=-Xlint +javac.release=21 diff --git a/ide/generic.project/nbproject/project.xml b/ide/generic.project/nbproject/project.xml new file mode 100644 index 000000000000..ea3d52ec0d57 --- /dev/null +++ b/ide/generic.project/nbproject/project.xml @@ -0,0 +1,135 @@ + + + + org.netbeans.modules.apisupport.project + + + org.netbeans.modules.generic.project + + + org.netbeans.api.annotations.common + + + + 1 + 1.48 + + + + org.netbeans.modules.projectapi + + + + 1 + 1.106 + + + + org.netbeans.modules.projectuiapi + + + + 1 + 1.95.0.8 + + + + org.openide.filesystems + + + + 9.17 + + + + org.openide.loaders + + + + 7.74 + + + + org.openide.nodes + + + + 7.52 + + + + org.openide.util + + + + 9.14 + + + + org.openide.util.ui + + + + 9.15 + + + + org.openide.util.lookup + + + + 8.40 + + + + + + unit + + org.netbeans.libs.junit4 + + + + org.netbeans.modules.masterfs + + + + + org.netbeans.modules.nbjunit + + + + + org.netbeans.modules.projectapi.nb + + + + org.openide.util.lookup + + + + + + + + + diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/Bundle.properties b/ide/generic.project/src/org/netbeans/modules/generic/project/Bundle.properties new file mode 100644 index 000000000000..25ec550017b6 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/Bundle.properties @@ -0,0 +1,19 @@ +# 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. + +OpenIDE-Module-Name=Generic Project +OpenIDE-Module-Display-Category=Base IDE diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java new file mode 100644 index 000000000000..d2abdc298157 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -0,0 +1,135 @@ +/* + * 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.generic.project; + +import java.beans.PropertyChangeListener; +import java.io.IOException; +import javax.swing.Icon; +import javax.swing.UIManager; +import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; +import org.netbeans.spi.project.ProjectFactory; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.ImageUtilities; +import org.openide.util.Lookup; +import org.openide.util.lookup.Lookups; +import org.openide.util.lookup.ServiceProvider; + +/** + * A generic project. Any directory containing the {@link #MARKER_FILE} is + * recognized as a generic project. The logical view of the project simply shows + * all files contained in the project directory. + */ +public final class GenericProject implements Project { + + public static final String MARKER_FILE = ".nbgp"; // NOI18N + + @StaticResource + public static final String ICON = "org/netbeans/modules/generic/project/gp.svg"; // NOI18N + + private final FileObject projectDirectory; + private final Lookup lookup; + + private GenericProject(FileObject projectDirectory) { + this.projectDirectory = projectDirectory; + this.lookup = Lookups.fixed( + new GenericProjectLogicalViewProvider(this), + new GenericProjectInfo(this), + new SubprojectProviderImpl(this), + this + ); + } + + @Override + public FileObject getProjectDirectory() { + return projectDirectory; + } + + @Override + public Lookup getLookup() { + return lookup; + } + + static boolean isProject(FileObject projectDirectory) { + return projectDirectory != null + && projectDirectory.getFileObject(MARKER_FILE) != null; + } + + @ServiceProvider(service=ProjectFactory.class) + public static final class GenericProjectFactory implements ProjectFactory { + + @Override + public boolean isProject(FileObject projectDirectory) { + return GenericProject.isProject(projectDirectory); + } + + @Override + public Project loadProject(FileObject projectDirectory, ProjectState state) throws IOException { + if (GenericProject.isProject(projectDirectory)) { + return new GenericProject(projectDirectory); + } + return null; + } + + @Override + public void saveProject(Project project) throws IOException, ClassCastException { + FileObject dir = project.getProjectDirectory(); + if (dir.getFileObject(MARKER_FILE) == null) { + FileObject marker = dir.createData(MARKER_FILE); + marker.setAttribute("displayName", dir.getNameExt()); + } + } + + } + + private record GenericProjectInfo(GenericProject prj) implements ProjectInformation { + + @Override + public String getName() { + return prj.getProjectDirectory().getNameExt(); + } + + @Override + public String getDisplayName() { + return prj.getProjectDirectory().getNameExt(); + } + + @Override + public Icon getIcon() { + Icon icon = ImageUtilities.loadIcon(ICON); + return icon != null ? icon : UIManager.getIcon("Nb.Explorer.Folder.icon"); // NOI18N + } + + @Override + public Project getProject() { + return prj; + } + + @Override + public void addPropertyChangeListener(PropertyChangeListener listener) {} + + @Override + public void removePropertyChangeListener(PropertyChangeListener listener) {} + + } + +} diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java new file mode 100644 index 000000000000..5f3b66decf41 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java @@ -0,0 +1,77 @@ +/* + * 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.generic.project; + +import java.awt.Image; +import javax.swing.Action; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.netbeans.spi.project.ui.support.CommonProjectActions; +import org.openide.filesystems.FileObject; +import org.openide.loaders.DataFilter; +import org.openide.loaders.DataFolder; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.Children; +import org.openide.nodes.Node; +import org.openide.util.ImageUtilities; +import org.openide.util.lookup.Lookups; + +/** + * The logical view of a generic project simply shows all files contained + * in the project directory. + */ +public record GenericProjectLogicalViewProvider(GenericProject project) implements LogicalViewProvider { + + @Override + public Node createLogicalView() { + FileObject dir = project.getProjectDirectory(); + Children children; + try { + children = DataFolder.findFolder(dir).createNodeChildren(DataFilter.ALL); + } catch (Exception ex) { + children = Children.LEAF; + } + AbstractNode base = new AbstractNode(children, Lookups.fixed(project, dir)) { + @Override + public Action[] getActions(boolean context) { + return new Action[] { + CommonProjectActions.closeProjectAction(), + }; + } + + @Override + public Image getIcon(int type) { + return ImageUtilities.loadImage(GenericProject.ICON); + } + + @Override + public Image getOpenedIcon(int type) { + return getIcon(type); + } + }; + base.setDisplayName(dir.getNameExt()); + return base; + } + + @Override + public Node findPath(Node root, Object target) { + return target instanceof FileObject fo && fo.equals(project.getProjectDirectory()) ? root : null; + } + +} diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/gp.png b/ide/generic.project/src/org/netbeans/modules/generic/project/gp.png new file mode 100644 index 0000000000000000000000000000000000000000..7f9b26179abb6084ea831b304373247167ed560f GIT binary patch literal 694 zcmV;n0!jUeP)Szdpb6|JUnlV0`1~>i-~dfB<62u53R8QY9=d z#qjFGR|XjS-@kthK)L@}6>SVZo-ATudv%QA@vGwuOiwS}*VfHp;AHxRq2|ZW-%J1j#PavwUj`;7Mh1M~$IrhE%v?g4;y?Z{00a=r z-#>&6`1$KM0}HnZrugqa3;+Sd^7ro_24*HEyaxORDy(SiWdK?89|##3fh=GEgD8Li z0vo_Wzy7A&K z0Rjl@f|oCzGkpB`k>SmUFAN_(e`6p77+IJZKD~PfG~mZpfB*ufgMSQfU%v*%={JUd zKpXx+ZNUeafnIq2>@i5sR)7Fv`TP6#tM6aGhyhJw;Ns+9`1hZH?Z60QXJcV__T&lB z_kWKA1Q4UGvriM3fMD6Jdr#OIhy*}0{xJMwm~!{RzCwTi0wn-Y5|f4I4`RV{V3a-s cF#rM#0Q=1h(tk=a)c^nh07*qoM6N<$f}mA0hyVZp literal 0 HcmV?d00001 diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/gp.svg b/ide/generic.project/src/org/netbeans/modules/generic/project/gp.svg new file mode 100644 index 000000000000..5f7295022b67 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/gp.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/gp24.png b/ide/generic.project/src/org/netbeans/modules/generic/project/gp24.png new file mode 100644 index 0000000000000000000000000000000000000000..50a4fa63a6986e19b3c3f31cead702c600c55c7d GIT binary patch literal 911 zcmV;A191F_P)&UBGYFJL2Q5kVu1+)4R6fPNMhvU<6}ft{OQvthKV!gH3FHF;*+y} zHN|WDg{3tx@QNv7HT?6d`wXjQHTZ!%3*rL=5ZHpW;s$#O2?>Fp46F>VKYd5j!pZ=W z1E~S}_F!Y2mYtHi9?+kEv05N3pv&;@-#k@I?1S`#NCu!jhIe1SW7S}+r^8@rtOs%#!<6hL z44h0q@mlcv55ji<0R*<-<@;|8OiWBTeenLv4@89j{zEY3AgcigAg~2Yj1Wu=`2G7Y z10xd)K{YS{5I|s+jEoRW4EP1~J`)EYK{YS{5I|517{Qnr@CT@wg4>Mk`*ZY|Ns9nEMK{XVg1rsCdlFop)^1M5$%J&fB!OU z+`fz9*r~G&808}~7lT(^!5Jv;AJE!0Ksp@60SF+l1#jQJX4rG+1jEU*7jTy^KrAFC z#$aym#K6LgHx)4*+_gOj#sLT*u*zRQe}Y|R@8${24|se6bouw6zZgFM_=-~rD=Q1b zzyJT?E&vE1FyqUoj|{(m{bcy^1S6=g<2v zF@OMK`S#^A!?$l=faS+;a0dDPn|uqHnVA_rzJCie>(^zN7(f8AJb(J=*|#t1QolhK z0F&Y$3ViVIA1ly;cfk1keHJDL5I`&s@7y@@<+DZDe_&Dk7l_$8*vYm4r2p0PCk%gn z|2hs60|+2SK0%>i78cfAE^$?RN?Q>B|NZ&*?e(+m?_NFhfr$YG5Gc{}0I@g_3s9kz l0!riWfmQ2cm;gY40RSRqI5E!2>AU~{002ovPDHLkV1i7)nq>e0 literal 0 HcmV?d00001 diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/gp24.svg b/ide/generic.project/src/org/netbeans/modules/generic/project/gp24.svg new file mode 100644 index 000000000000..c10101837e94 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/gp24.svg @@ -0,0 +1,68 @@ + + + + + + + + Apache NetBeans Logo + + + + + + + + + + + + + + + + + + + + + diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java new file mode 100644 index 000000000000..f5e15ce5bcad --- /dev/null +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -0,0 +1,77 @@ +/* + * 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.generic.project; + +import java.io.File; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectManager; +import org.netbeans.junit.NbTestCase; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.nodes.Node; +import org.openide.util.test.MockLookup; + +public class GenericProjectTest extends NbTestCase { + + static { + // ensures the default lookup includes the meta-inf services, + // e.g. MasterURLMapper and the ProjectManagerImplementation + MockLookup.init(); + } + + public GenericProjectTest(String name) { + super(name); + } + + public void testNoMarkerNotProject() throws Exception { + FileObject root = newProjectDir(); + assertFalse(new GenericProject.GenericProjectFactory().isProject(root)); + } + + public void testMarkerFileIsProject() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + assertTrue(new GenericProject.GenericProjectFactory().isProject(root)); + } + + public void testLogicalViewShowsAllFiles() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileUtil.createData(root, "readme.txt"); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + assertTrue(prj instanceof GenericProject); + LogicalViewProvider provider = prj.getLookup().lookup(LogicalViewProvider.class); + assertNotNull(provider); + Node node = provider.createLogicalView(); + assertNotNull(node); + Node[] children = node.getChildren().getNodes(true); + assertEquals(2, children.length); + } + + private FileObject newProjectDir() throws Exception { + clearWorkDir(); + File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N + assertTrue(dir.mkdirs() || dir.isDirectory()); + return FileUtil.toFileObject(dir); + } + +} diff --git a/nbbuild/cluster.properties b/nbbuild/cluster.properties index a668f4e128e3..3634081dd8ae 100644 --- a/nbbuild/cluster.properties +++ b/nbbuild/cluster.properties @@ -344,6 +344,7 @@ nb.cluster.ide=\ extexecution.base,\ extexecution.impl,\ extexecution.process,\ + generic.project,\ git,\ go.lang,\ gototest,\ From e2f7415770b6411189c9ad04adfc8f5b58843f56 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 10:47:22 +0200 Subject: [PATCH 03/15] project wizard --- ide/generic.project/nbproject/project.xml | 24 ++ .../generic/project/GenericProject.java | 19 +- .../GenericProjectTemplateDescription.html | 29 ++ .../project/GenericProjectWizardIterator.java | 152 ++++++++++ .../project/GenericProjectWizardPanel.java | 280 ++++++++++++++++++ 5 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java diff --git a/ide/generic.project/nbproject/project.xml b/ide/generic.project/nbproject/project.xml index ea3d52ec0d57..d465bcf47ae2 100644 --- a/ide/generic.project/nbproject/project.xml +++ b/ide/generic.project/nbproject/project.xml @@ -34,6 +34,14 @@ 1.48 + + org.netbeans.api.templates + + + + 1.28 + + org.netbeans.modules.projectapi @@ -52,6 +60,22 @@ 1.95.0.8 + + org.openide.awt + + + + 7.88 + + + + org.openide.dialogs + + + + 7.66 + + org.openide.filesystems diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index d2abdc298157..19a89baff325 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -74,6 +74,19 @@ static boolean isProject(FileObject projectDirectory) { && projectDirectory.getFileObject(MARKER_FILE) != null; } + /** + * Creates the marker file in the given directory if it does not + * exist yet, returning the marker. + */ + static FileObject createMarker(FileObject dir) throws IOException { + FileObject marker = dir.getFileObject(MARKER_FILE); + if (marker == null) { + marker = dir.createData(MARKER_FILE); + marker.setAttribute("displayName", dir.getNameExt()); // NOI18N + } + return marker; + } + @ServiceProvider(service=ProjectFactory.class) public static final class GenericProjectFactory implements ProjectFactory { @@ -92,11 +105,7 @@ public Project loadProject(FileObject projectDirectory, ProjectState state) thro @Override public void saveProject(Project project) throws IOException, ClassCastException { - FileObject dir = project.getProjectDirectory(); - if (dir.getFileObject(MARKER_FILE) == null) { - FileObject marker = dir.createData(MARKER_FILE); - marker.setAttribute("displayName", dir.getNameExt()); - } + createMarker(project.getProjectDirectory()); } } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html new file mode 100644 index 000000000000..f5ab3415e06b --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html @@ -0,0 +1,29 @@ + + + + + + + Creates a new generic project. the project's logical view + shows all files (including sub projects) contained in the folder. + + diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java new file mode 100644 index 000000000000..28397f92729a --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java @@ -0,0 +1,152 @@ +/* + * 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.generic.project; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Set; +import javax.swing.JComponent; +import javax.swing.event.ChangeListener; +import org.netbeans.api.templates.TemplateRegistration; +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.WizardDescriptor; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.NbBundle.Messages; + +/** + * The "New Generic Project" wizard. Instantiating it creates the project + * directory with the {@link GenericProject#MARKER_FILE} — or, if "keep + * repository clean" is selected, as a local project in NB's user config. + */ +@TemplateRegistration( + folder = "Project/Generic", // NOI18N + displayName = "#GenericProjectWizard_displayName", // NOI18N + description = "GenericProjectTemplateDescription.html", // NOI18N + iconBase = GenericProject.ICON, + position = 1000 +) +@Messages("GenericProjectWizard_displayName=New Generic Project") +public final class GenericProjectWizardIterator implements WizardDescriptor.InstantiatingIterator { + + private WizardDescriptor wiz; + private List> panels; + private int index; + + private List> createPanels() { + return List.of(new GenericProjectWizardPanel()); + } + + @Override + public Set instantiate() throws IOException { + File projectDir = FileUtil.normalizeFile((File) wiz.getProperty("projdir")); // NOI18N + Set created = createProject(projectDir); + File parent = projectDir.getParentFile(); + if (parent != null && parent.exists()) { + ProjectChooser.setProjectsFolder(parent); + } + return created; + } + + /** + * Creates the project directory containing the marker file. + */ + static Set createProject(File projectDir) throws IOException { + if (!projectDir.exists() && !projectDir.mkdirs()) { + throw new IOException("Cannot create folder: " + projectDir); // NOI18N + } + FileObject dir = FileUtil.toFileObject(projectDir); + if (dir == null) { + throw new IOException("Cannot map folder to the file system: " + projectDir); // NOI18N + } + GenericProject.createMarker(dir); + return Set.of(dir); + } + + @Override + public void initialize(WizardDescriptor wiz) { + this.wiz = wiz; + index = 0; + panels = createPanels(); + for (int i = 0; i < panels.size(); i++) { + if (panels.get(i).getComponent() instanceof JComponent jc) { + // Step #. + jc.putClientProperty("WizardPanel_contentSelectedIndex", i); // NOI18N + // The step names are the component names of the panels. + jc.putClientProperty("WizardPanel_contentData", jc.getName()); // NOI18N + } + } + } + + @Override + public void uninitialize(WizardDescriptor wiz) { + this.wiz.putProperty("projdir", null); // NOI18N + this.wiz.putProperty("name", null); // NOI18N + this.wiz = null; + panels = null; + } + + @Override + public String name() { + return "%d of %d".formatted(index + 1, panels.size()); // NOI18N + } + + @Override + public boolean hasNext() { + return index < panels.size() - 1; + } + + @Override + public boolean hasPrevious() { + return index > 0; + } + + @Override + public void nextPanel() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + index++; + } + + @Override + public void previousPanel() { + if (!hasPrevious()) { + throw new NoSuchElementException(); + } + index--; + } + + @Override + public WizardDescriptor.Panel current() { + return panels.get(index); + } + + @Override + public final void addChangeListener(ChangeListener l) { + } + + @Override + public final void removeChangeListener(ChangeListener l) { + } + +} diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java new file mode 100644 index 000000000000..38344297ec8d --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java @@ -0,0 +1,280 @@ +/* + * 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.generic.project; + +import java.awt.Component; +import java.io.File; +import javax.swing.GroupLayout; +import javax.swing.GroupLayout.Alignment; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.LayoutStyle; +import javax.swing.JTextField; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.Document; +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.WizardDescriptor; +import org.openide.WizardValidationException; +import org.openide.awt.Mnemonics; +import org.openide.filesystems.FileUtil; +import org.openide.util.ChangeSupport; +import org.openide.util.NbBundle.Messages; + +/** + * The single wizard panel asking for the project name and location. + */ +@Messages({ + "LBL_CreateProjectStep=Name and Location", + "LBL_ProjectName=Project &Name:", + "LBL_ProjectLocation=Project &Location:", + "LBL_CreatedFolder=Project &Folder:", + "LBL_Browse=Br&owse...", + "LBL_BrowseDialogTitle=Select Project Location", + "MSG_DefaultProjectName=MyGenericProject", + "ERR_InvalidName=Project Name is not a valid folder name.", + "ERR_InvalidPath=Project Folder is not a valid path.", + "ERR_NotWritable=Project Folder cannot be created.", + "ERR_FolderExists=Project Folder already exists and is not empty.", +}) +final class GenericProjectWizardPanel implements WizardDescriptor.Panel, + WizardDescriptor.ValidatingPanel, + WizardDescriptor.FinishablePanel, + DocumentListener { + + private WizardDescriptor wiz; + private JComponent component; + + private final JTextField projectName = new JTextField(); + private final JTextField projectLocation = new JTextField(); + private final JTextField createdFolder = new JTextField(); + private final JButton browseButton = new JButton(); + private final ChangeSupport changeSupport = new ChangeSupport(this); + + @Override + public Component getComponent() { + if (component == null) { + component = createComponent(); + } + return component; + } + + @Override + public org.openide.util.HelpCtx getHelp() { + return null; + } + + @Override + public boolean isValid() { + getComponent(); + return valid(wiz); + } + + @Override + public final void addChangeListener(ChangeListener l) { + changeSupport.addChangeListener(l); + } + + @Override + public final void removeChangeListener(ChangeListener l) { + changeSupport.removeChangeListener(l); + } + + private void fireChangeEvent() { + changeSupport.fireChange(); + } + + @Override + public void readSettings(WizardDescriptor settings) { + wiz = settings; + File location = (File) wiz.getProperty("projdir"); // NOI18N + if (location == null || location.getParentFile() == null || !location.getParentFile().isDirectory()) { + location = ProjectChooser.getProjectsFolder(); + } else { + location = location.getParentFile(); + } + projectLocation.setText(location.getAbsolutePath()); + + String name = (String) wiz.getProperty("name"); // NOI18N + projectName.setText(name == null ? Bundle.MSG_DefaultProjectName() : name); + projectName.selectAll(); + updateCreatedFolder(); + } + + @Override + public void storeSettings(WizardDescriptor settings) { + settings.putProperty("name", projectName.getText().strip()); // NOI18N + settings.putProperty("projdir", new File(createdFolder.getText().strip())); // NOI18N + } + + @Override + public boolean isFinishPanel() { + return true; + } + + @Override + public void validate() throws WizardValidationException { + // nothing to validate + } + + private boolean valid(WizardDescriptor d) { + if (projectName.getText().isEmpty()) { + d.putProperty("WizardPanel_errorMessage", Bundle.ERR_InvalidName()); // NOI18N + return false; + } + File location = FileUtil.normalizeFile(new File(projectLocation.getText()).getAbsoluteFile()); + if (!location.isDirectory()) { + d.putProperty("WizardPanel_errorMessage", Bundle.ERR_InvalidPath()); // NOI18N + return false; + } + File destFolder = FileUtil.normalizeFile(new File(createdFolder.getText()).getAbsoluteFile()); + File parent = destFolder; + while (parent != null && !parent.exists()) { + parent = parent.getParentFile(); + } + if (parent == null || !parent.canWrite()) { + d.putProperty("WizardPanel_errorMessage", Bundle.ERR_NotWritable()); // NOI18N + return false; + } + if (FileUtil.toFileObject(parent) == null) { + d.putProperty("WizardPanel_errorMessage", Bundle.ERR_InvalidPath()); // NOI18N + return false; + } + File[] kids = destFolder.listFiles(); + if (destFolder.exists() && kids != null && kids.length > 0) { + // Folder exists and is not empty + d.putProperty("WizardPanel_errorMessage", Bundle.ERR_FolderExists()); // NOI18N + return false; + } + d.putProperty("WizardPanel_errorMessage", ""); // NOI18N + return true; + } + + private JComponent createComponent() { + JLabel nameLabel = new JLabel(); + Mnemonics.setLocalizedText(nameLabel, Bundle.LBL_ProjectName()); + JLabel locationLabel = new JLabel(); + Mnemonics.setLocalizedText(locationLabel, Bundle.LBL_ProjectLocation()); + JLabel folderLabel = new JLabel(); + Mnemonics.setLocalizedText(folderLabel, Bundle.LBL_CreatedFolder()); + createdFolder.setEditable(false); + Mnemonics.setLocalizedText(browseButton, Bundle.LBL_Browse()); + browseButton.addActionListener(e -> browse()); + + // Making all labels stretchable snaps the shorter ones to + // the widest label, so the fields start at the same x and + // stretch to the edge. + JPanel panel = new JPanel(); + GroupLayout layout = new GroupLayout(panel); + panel.setLayout(layout); + layout.setHorizontalGroup( + layout.createParallelGroup(Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(Alignment.LEADING, false) + .addComponent(nameLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) + .addComponent(locationLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) + .addComponent(folderLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(Alignment.LEADING) + .addComponent(projectName, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) + .addGroup(layout.createSequentialGroup() + .addComponent(projectLocation, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addComponent(browseButton)) + .addComponent(createdFolder, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE)) + .addContainerGap()) + ); + layout.setVerticalGroup( + layout.createParallelGroup(Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addGroup(layout.createParallelGroup(Alignment.BASELINE) + .addComponent(nameLabel) + .addComponent(projectName)) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(Alignment.BASELINE) + .addComponent(locationLabel) + .addComponent(projectLocation) + .addComponent(browseButton)) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(Alignment.BASELINE) + .addComponent(folderLabel) + .addComponent(createdFolder)) + .addGap(0, 0, Short.MAX_VALUE) + ) + ); + projectName.getDocument().addDocumentListener(this); + projectLocation.getDocument().addDocumentListener(this); + + panel.setName(Bundle.LBL_CreateProjectStep()); + return panel; + } + + private void browse() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(Bundle.LBL_BrowseDialogTitle()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + String path = projectLocation.getText(); + if (!path.isEmpty()) { + File f = new File(path); + if (f.exists()) { + chooser.setSelectedFile(f); + } + } + if (JFileChooser.APPROVE_OPTION == chooser.showOpenDialog(component)) { + projectLocation.setText(FileUtil.normalizeFile(chooser.getSelectedFile()).getAbsolutePath()); + } + fireChangeEvent(); + } + + // Implementation of DocumentListener -------------------------------------- + + @Override + public void changedUpdate(DocumentEvent e) { + updateTexts(e); + } + + @Override + public void insertUpdate(DocumentEvent e) { + updateTexts(e); + } + + @Override + public void removeUpdate(DocumentEvent e) { + updateTexts(e); + } + + private void updateTexts(DocumentEvent e) { + Document doc = e.getDocument(); + if (doc == projectName.getDocument() || doc == projectLocation.getDocument()) { + updateCreatedFolder(); + } + fireChangeEvent(); + } + + private void updateCreatedFolder() { + createdFolder.setText(projectLocation.getText() + File.separator + projectName.getText()); + } + +} From 2632c1a00b42a70571e7f3aca6a73483e059e127 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 09:48:51 +0200 Subject: [PATCH 04/15] [action] open required projects --- .../generic/project/GenericProject.java | 49 ++++++++++++++++++- .../GenericProjectLogicalViewProvider.java | 1 + .../generic/project/GenericProjectTest.java | 21 ++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 19a89baff325..990695a6fd6a 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -21,14 +21,21 @@ import java.beans.PropertyChangeListener; import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; import javax.swing.Icon; import javax.swing.UIManager; +import javax.swing.event.ChangeListener; import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.api.project.FileOwnerQuery; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.spi.project.ProjectFactory; import org.netbeans.spi.project.ProjectState; +import org.netbeans.spi.project.SubprojectProvider; import org.openide.filesystems.FileObject; +import org.openide.util.ChangeSupport; import org.openide.util.ImageUtilities; import org.openide.util.Lookup; import org.openide.util.lookup.Lookups; @@ -54,7 +61,7 @@ private GenericProject(FileObject projectDirectory) { this.lookup = Lookups.fixed( new GenericProjectLogicalViewProvider(this), new GenericProjectInfo(this), - new SubprojectProviderImpl(this), + new GenericSubprojectProvider(this), this ); } @@ -141,4 +148,44 @@ public void removePropertyChangeListener(PropertyChangeListener listener) {} } + /** + * Exposes the projects contained directly in the project directory + * as subprojects. Adds "Open Required Projects" and "Open Selected Project" actions. + */ + private static final class GenericSubprojectProvider implements SubprojectProvider { + + private final GenericProject prj; + private final ChangeSupport changeSupport = new ChangeSupport(this); + + GenericSubprojectProvider(GenericProject prj) { + this.prj = prj; + } + + @Override + public Set getSubprojects() { + Set subprojects = new LinkedHashSet<>(); + FileObject[] children = prj.getProjectDirectory().getChildren(); + if (children != null) { + for (FileObject child : children) { + Project owner = FileOwnerQuery.getOwner(child); + if (owner != null && !owner.equals(prj)) { + subprojects.add(owner); + } + } + } + return Collections.unmodifiableSet(subprojects); + } + + @Override + public void addChangeListener(ChangeListener listener) { + changeSupport.addChangeListener(listener); + } + + @Override + public void removeChangeListener(ChangeListener listener) { + changeSupport.removeChangeListener(listener); + } + + } + } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java index 5f3b66decf41..41feb6c40fa3 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java @@ -51,6 +51,7 @@ public Node createLogicalView() { @Override public Action[] getActions(boolean context) { return new Action[] { + CommonProjectActions.openSubprojectsAction(), CommonProjectActions.closeProjectAction(), }; } diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index f5e15ce5bcad..d3e187666d23 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -20,9 +20,11 @@ package org.netbeans.modules.generic.project; import java.io.File; +import java.util.Set; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectManager; import org.netbeans.junit.NbTestCase; +import org.netbeans.spi.project.SubprojectProvider; import org.netbeans.spi.project.ui.LogicalViewProvider; import org.openide.filesystems.FileObject; import org.openide.filesystems.FileUtil; @@ -67,6 +69,25 @@ public void testLogicalViewShowsAllFiles() throws Exception { assertEquals(2, children.length); } + public void testSubprojectsAreFirstLevelOnly() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileObject child = FileUtil.createFolder(root, "child"); // NOI18N + FileUtil.createData(child, GenericProject.MARKER_FILE); + FileUtil.createFolder(root, "plain"); // NOI18N + FileUtil.createData(root, "readme.txt"); // NOI18N + FileObject deep = FileUtil.createFolder(FileUtil.createFolder(child, "inner"), "deep"); // NOI18N + FileUtil.createData(deep, GenericProject.MARKER_FILE); + + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + SubprojectProvider spp = prj.getLookup().lookup(SubprojectProvider.class); + assertNotNull(spp); + Set subs = spp.getSubprojects(); + assertEquals(1, subs.size()); + assertEquals(child, subs.iterator().next().getProjectDirectory()); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N From e2c95470e43bc9c5367a016ccf54fb09fc290353 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 12:03:51 +0200 Subject: [PATCH 05/15] [action] rename action + some refactoring --- .github/workflows/main.yml | 3 ++ ide/generic.project/manifest.mf | 3 +- .../generic/project/GenericProject.java | 14 ++++----- ...ovider.java => GpLogicalViewProvider.java} | 2 +- ...iption.html => GpTemplateDescription.html} | 0 ...ardIterator.java => GpWizardIterator.java} | 10 +++--- ...ectWizardPanel.java => GpWizardPanel.java} | 2 +- .../modules/generic/project/layer.xml | 31 +++++++++++++++++++ .../generic/project/GenericProjectTest.java | 18 +++++++++-- 9 files changed, 66 insertions(+), 17 deletions(-) rename ide/generic.project/src/org/netbeans/modules/generic/project/{GenericProjectLogicalViewProvider.java => GpLogicalViewProvider.java} (96%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{GenericProjectTemplateDescription.html => GpTemplateDescription.html} (100%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{GenericProjectWizardIterator.java => GpWizardIterator.java} (92%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{GenericProjectWizardPanel.java => GpWizardPanel.java} (99%) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e4f4debfbd6..4cc525d6f9ba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -661,6 +661,9 @@ jobs: - name: ide/extexecution.base run: ant $OPTS -f ide/extexecution.base test + - name: ide/generic.project + run: ant $OPTS -f ide/generic.project test + - name: ide/gsf.testrunner.ui run: ant $OPTS -f ide/gsf.testrunner.ui test diff --git a/ide/generic.project/manifest.mf b/ide/generic.project/manifest.mf index 177f6e972319..ffd91fe6bd8d 100644 --- a/ide/generic.project/manifest.mf +++ b/ide/generic.project/manifest.mf @@ -1,4 +1,5 @@ Manifest-Version: 1.0 OpenIDE-Module: org.netbeans.modules.generic.project +OpenIDE-Module-Layer: org/netbeans/modules/generic/project/layer.xml OpenIDE-Module-Localizing-Bundle: org/netbeans/modules/generic/project/Bundle.properties -OpenIDE-Module-Specification-Version: 1.0 \ No newline at end of file +OpenIDE-Module-Specification-Version: 1.0 diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 990695a6fd6a..2ecb17deac1d 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -59,9 +59,9 @@ public final class GenericProject implements Project { private GenericProject(FileObject projectDirectory) { this.projectDirectory = projectDirectory; this.lookup = Lookups.fixed( - new GenericProjectLogicalViewProvider(this), - new GenericProjectInfo(this), - new GenericSubprojectProvider(this), + new GpLogicalViewProvider(this), + new GpInfo(this), + new GpSubprojectProvider(this), this ); } @@ -95,7 +95,7 @@ static FileObject createMarker(FileObject dir) throws IOException { } @ServiceProvider(service=ProjectFactory.class) - public static final class GenericProjectFactory implements ProjectFactory { + public static final class GpFactory implements ProjectFactory { @Override public boolean isProject(FileObject projectDirectory) { @@ -117,7 +117,7 @@ public void saveProject(Project project) throws IOException, ClassCastException } - private record GenericProjectInfo(GenericProject prj) implements ProjectInformation { + private record GpInfo(GenericProject prj) implements ProjectInformation { @Override public String getName() { @@ -152,12 +152,12 @@ public void removePropertyChangeListener(PropertyChangeListener listener) {} * Exposes the projects contained directly in the project directory * as subprojects. Adds "Open Required Projects" and "Open Selected Project" actions. */ - private static final class GenericSubprojectProvider implements SubprojectProvider { + private static final class GpSubprojectProvider implements SubprojectProvider { private final GenericProject prj; private final ChangeSupport changeSupport = new ChangeSupport(this); - GenericSubprojectProvider(GenericProject prj) { + GpSubprojectProvider(GenericProject prj) { this.prj = prj; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java similarity index 96% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java index 41feb6c40fa3..7846114e9bcf 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectLogicalViewProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java @@ -36,7 +36,7 @@ * The logical view of a generic project simply shows all files contained * in the project directory. */ -public record GenericProjectLogicalViewProvider(GenericProject project) implements LogicalViewProvider { +public record GpLogicalViewProvider(GenericProject project) implements LogicalViewProvider { @Override public Node createLogicalView() { diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html b/ide/generic.project/src/org/netbeans/modules/generic/project/GpTemplateDescription.html similarity index 100% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectTemplateDescription.html rename to ide/generic.project/src/org/netbeans/modules/generic/project/GpTemplateDescription.html diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java similarity index 92% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java index 28397f92729a..6b15fdd44913 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardIterator.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java @@ -40,20 +40,20 @@ */ @TemplateRegistration( folder = "Project/Generic", // NOI18N - displayName = "#GenericProjectWizard_displayName", // NOI18N - description = "GenericProjectTemplateDescription.html", // NOI18N + displayName = "#GpWizard_displayName", // NOI18N + description = "GpTemplateDescription.html", // NOI18N iconBase = GenericProject.ICON, position = 1000 ) -@Messages("GenericProjectWizard_displayName=New Generic Project") -public final class GenericProjectWizardIterator implements WizardDescriptor.InstantiatingIterator { +@Messages("GpWizard_displayName=New Generic Project") +public final class GpWizardIterator implements WizardDescriptor.InstantiatingIterator { private WizardDescriptor wiz; private List> panels; private int index; private List> createPanels() { - return List.of(new GenericProjectWizardPanel()); + return List.of(new GpWizardPanel()); } @Override diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java similarity index 99% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java index 38344297ec8d..6b165ac092c1 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProjectWizardPanel.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java @@ -58,7 +58,7 @@ "ERR_NotWritable=Project Folder cannot be created.", "ERR_FolderExists=Project Folder already exists and is not empty.", }) -final class GenericProjectWizardPanel implements WizardDescriptor.Panel, +final class GpWizardPanel implements WizardDescriptor.Panel, WizardDescriptor.ValidatingPanel, WizardDescriptor.FinishablePanel, DocumentListener { diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml new file mode 100644 index 000000000000..480e9e1ee993 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index d3e187666d23..deddb60c76d3 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -45,13 +45,13 @@ public GenericProjectTest(String name) { public void testNoMarkerNotProject() throws Exception { FileObject root = newProjectDir(); - assertFalse(new GenericProject.GenericProjectFactory().isProject(root)); + assertFalse(new GenericProject.GpFactory().isProject(root)); } public void testMarkerFileIsProject() throws Exception { FileObject root = newProjectDir(); FileUtil.createData(root, GenericProject.MARKER_FILE); - assertTrue(new GenericProject.GenericProjectFactory().isProject(root)); + assertTrue(new GenericProject.GpFactory().isProject(root)); } public void testLogicalViewShowsAllFiles() throws Exception { @@ -88,6 +88,20 @@ public void testSubprojectsAreFirstLevelOnly() throws Exception { assertEquals(child, subs.iterator().next().getProjectDirectory()); } + public void testCreateProject() throws Exception { + clearWorkDir(); + File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N + Set created = GpWizardIterator.createProject(dir); + assertEquals(1, created.size()); + FileObject dirFo = created.iterator().next(); + FileObject marker = dirFo.getFileObject(GenericProject.MARKER_FILE); + assertNotNull(marker); + assertEquals(dir.getName(), marker.getAttribute("displayName")); // NOI18N + Project prj = ProjectManager.getDefault().findProject(dirFo); + assertNotNull(prj); + assertTrue(prj instanceof GenericProject); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N From d476e3e3d8abbbb78f166f0747897f03fde14489 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 13:38:54 +0200 Subject: [PATCH 06/15] general project properties --- .../generic/project/GenericProject.java | 9 ++ .../project/GpLogicalViewProvider.java | 1 + .../GpCompositeCategoryProvider.java | 112 ++++++++++++++++++ .../ui/customizer/GpCustomizerProvider.java | 56 +++++++++ 4 files changed, 178 insertions(+) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCustomizerProvider.java diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 2ecb17deac1d..d2dbf0f234fd 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -31,6 +31,7 @@ import org.netbeans.api.project.FileOwnerQuery; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; +import org.netbeans.modules.generic.project.ui.customizer.GpCustomizerProvider; import org.netbeans.spi.project.ProjectFactory; import org.netbeans.spi.project.ProjectState; import org.netbeans.spi.project.SubprojectProvider; @@ -50,6 +51,13 @@ public final class GenericProject implements Project { public static final String MARKER_FILE = ".nbgp"; // NOI18N + /** + * The project type key used for this project type's contributions + * in the layer filesystem + * (e.g. {@code Projects/org-netbeans-modules-generic-project/Customizer}). + */ + public static final String PROJECT_KEY = "org-netbeans-modules-generic-project"; // NOI18N + @StaticResource public static final String ICON = "org/netbeans/modules/generic/project/gp.svg"; // NOI18N @@ -62,6 +70,7 @@ private GenericProject(FileObject projectDirectory) { new GpLogicalViewProvider(this), new GpInfo(this), new GpSubprojectProvider(this), + new GpCustomizerProvider(this), this ); } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java index 7846114e9bcf..f77708484603 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java @@ -53,6 +53,7 @@ public Action[] getActions(boolean context) { return new Action[] { CommonProjectActions.openSubprojectsAction(), CommonProjectActions.closeProjectAction(), + CommonProjectActions.customizeProjectAction(), }; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java new file mode 100644 index 000000000000..f70baf14bd3d --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java @@ -0,0 +1,112 @@ +/* + * 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.generic.project.ui.customizer; + +import javax.swing.GroupLayout; +import javax.swing.GroupLayout.Alignment; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.LayoutStyle; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; +import org.netbeans.modules.generic.project.GenericProject; +import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; + +/** + * Provides the "General" category of the project customizer, showing + * the project name and location (both read-only). + * + *

Additional categories can be added to the same dialog by registering + * further {@link ProjectCustomizer.CompositeCategoryProvider} instances + * for {@link GenericProject#PROJECT_KEY}. + */ +@Messages({ + "LBL_General=General", + "LBL_Name=Name:", + "LBL_Location=Location:", +}) +public final class GpCompositeCategoryProvider implements ProjectCustomizer.CompositeCategoryProvider { + + private static final String CATEGORY_GENERAL = "category-general"; // NOI18N + + @ProjectCustomizer.CompositeCategoryProvider.Registration( + projectType=GenericProject.PROJECT_KEY, + position=100 + ) + public static GpCompositeCategoryProvider createGeneralCategory() { + return new GpCompositeCategoryProvider(); + } + + private GpCompositeCategoryProvider() { + } + + @Override + public ProjectCustomizer.Category createCategory(Lookup context) { + return ProjectCustomizer.Category.create(CATEGORY_GENERAL, Bundle.LBL_General(), null); + } + + @Override + public JComponent createComponent(ProjectCustomizer.Category category, Lookup context) { + Project prj = context.lookup(Project.class); + ProjectInformation info = prj != null ? prj.getLookup().lookup(ProjectInformation.class) : null; + + JLabel nameLabel = new JLabel(Bundle.LBL_Name()); + JLabel locationLabel = new JLabel(Bundle.LBL_Location()); + JTextField nameField = new JTextField(info != null ? info.getName() : ""); + JTextField locationField = new JTextField(prj != null ? prj.getProjectDirectory().getPath() : ""); + nameField.setEditable(false); + locationField.setEditable(false); + + JPanel panel = new JPanel(); + GroupLayout layout = new GroupLayout(panel); + panel.setLayout(layout); + layout.setHorizontalGroup( + layout.createParallelGroup(Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(Alignment.LEADING, false) + .addComponent(nameLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) + .addComponent(locationLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(Alignment.LEADING) + .addComponent(nameField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) + .addComponent(locationField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE)) + .addContainerGap()) + ); + layout.setVerticalGroup( + layout.createParallelGroup(Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addGroup(layout.createParallelGroup(Alignment.BASELINE) + .addComponent(nameLabel) + .addComponent(nameField)) + .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(Alignment.BASELINE) + .addComponent(locationLabel) + .addComponent(locationField)) + .addGap(0, 0, Short.MAX_VALUE)) + ); + return panel; + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCustomizerProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCustomizerProvider.java new file mode 100644 index 000000000000..32fe4b434b17 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCustomizerProvider.java @@ -0,0 +1,56 @@ +/* + * 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.generic.project.ui.customizer; + +import java.awt.Dialog; +import org.netbeans.modules.generic.project.GenericProject; +import org.netbeans.spi.project.ui.CustomizerProvider2; +import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.openide.util.HelpCtx; + +/** + * Shows the project properties (customizer) window for a generic project. + */ +public final class GpCustomizerProvider implements CustomizerProvider2 { + + private final GenericProject prj; + + public GpCustomizerProvider(GenericProject prj) { + this.prj = prj; + } + + @Override + public void showCustomizer() { + showCustomizer(null, null); + } + + @Override + public void showCustomizer(String preselectedCategory, String preselectedSubCategory) { + Dialog dialog = ProjectCustomizer.createCustomizerDialog( + "Projects/" + GenericProject.PROJECT_KEY + "/Customizer", // NOI18N + prj.getLookup(), + preselectedCategory, + evt -> { + }, + HelpCtx.DEFAULT_HELP); + dialog.setVisible(true); + } + +} \ No newline at end of file From df8802b3b3bd9e5ab6452d8ffbf0da212fdb7044 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 15:39:06 +0200 Subject: [PATCH 07/15] marker now optional --- .../generic/project/GenericProject.java | 67 +++++++++++++++++-- .../generic/project/GpWizardIterator.java | 16 +++-- .../generic/project/GpWizardPanel.java | 15 +++++ .../GpCompositeCategoryProvider.java | 31 ++++++++- .../generic/project/GenericProjectTest.java | 48 ++++++++++++- 5 files changed, 167 insertions(+), 10 deletions(-) diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index d2dbf0f234fd..454e0475d8af 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -43,14 +43,22 @@ import org.openide.util.lookup.ServiceProvider; /** - * A generic project. Any directory containing the {@link #MARKER_FILE} is - * recognized as a generic project. The logical view of the project simply shows + * A generic project. A directory is recognized as a generic project + * if it contains the {@link #MARKER_FILE} or, for local + * (private) projects, if it carries the {@link #MARKER_ATTRIBUTE} + * FileObject attribute. The logical view of the project simply shows * all files contained in the project directory. */ public final class GenericProject implements Project { public static final String MARKER_FILE = ".nbgp"; // NOI18N + /** + * Name of the FileObject attribute that marks a project directory + * as a generic project without the marker file (local project). + */ + public static final String MARKER_ATTRIBUTE = "org.netbeans.modules.generic.project"; // NOI18N + /** * The project type key used for this project type's contributions * in the layer filesystem @@ -87,7 +95,23 @@ public Lookup getLookup() { static boolean isProject(FileObject projectDirectory) { return projectDirectory != null - && projectDirectory.getFileObject(MARKER_FILE) != null; + && (hasMarker(projectDirectory) || isAttributeProject(projectDirectory)); + } + + /** + * Whether the given directory contains the marker file. + */ + public static boolean hasMarker(FileObject dir) { + return dir != null && dir.getFileObject(MARKER_FILE) != null; + } + + /** + * Whether the given directory is marked as a generic project by + * the {@link #MARKER_ATTRIBUTE} FileObject attribute (local + * project). + */ + public static boolean isAttributeProject(FileObject dir) { + return dir != null && Boolean.TRUE.equals(dir.getAttribute(MARKER_ATTRIBUTE)); } /** @@ -103,6 +127,34 @@ static FileObject createMarker(FileObject dir) throws IOException { return marker; } + /** + * Adjusts the directory so the marker is (or isn't) a file in the + * working tree. + * + *

Turning the marker off stores the {@link #MARKER_ATTRIBUTE} + * before deleting the marker file, and turning it on + * creates the marker before clearing the attribute, so + * the directory is never in a state where it is unrecognized. + * + * @param dir the project directory + * @param markerFile whether the marker file should exist in the + * working tree afterwards + */ + public static void setMarkerState(FileObject dir, boolean markerFile) throws IOException { + if (markerFile) { + createMarker(dir); + if (isAttributeProject(dir)) { + dir.setAttribute(MARKER_ATTRIBUTE, null); + } + } else { + dir.setAttribute(MARKER_ATTRIBUTE, Boolean.TRUE); + FileObject marker = dir.getFileObject(MARKER_FILE); + if (marker != null) { + marker.delete(); + } + } + } + @ServiceProvider(service=ProjectFactory.class) public static final class GpFactory implements ProjectFactory { @@ -121,7 +173,14 @@ public Project loadProject(FileObject projectDirectory, ProjectState state) thro @Override public void saveProject(Project project) throws IOException, ClassCastException { - createMarker(project.getProjectDirectory()); + FileObject dir = project.getProjectDirectory(); + if (isAttributeProject(dir)) { + // local project: recognition lives in the FileObject + // attribute, never (re)create the marker file + dir.setAttribute(MARKER_ATTRIBUTE, Boolean.TRUE); + } else { + createMarker(dir); + } } } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java index 6b15fdd44913..7b18eadf1fa2 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java @@ -59,7 +59,8 @@ private List> createPanels() { @Override public Set instantiate() throws IOException { File projectDir = FileUtil.normalizeFile((File) wiz.getProperty("projdir")); // NOI18N - Set created = createProject(projectDir); + boolean localMarker = Boolean.TRUE.equals(wiz.getProperty("localMarker")); // NOI18N + Set created = createProject(projectDir, localMarker); File parent = projectDir.getParentFile(); if (parent != null && parent.exists()) { ProjectChooser.setProjectsFolder(parent); @@ -68,9 +69,11 @@ public Set instantiate() throws IOException { } /** - * Creates the project directory containing the marker file. + * Creates the project directory containing the marker file, or — + * for {@code localMarker} — marks it as a local project by the + * FileObject attribute only. */ - static Set createProject(File projectDir) throws IOException { + static Set createProject(File projectDir, boolean localMarker) throws IOException { if (!projectDir.exists() && !projectDir.mkdirs()) { throw new IOException("Cannot create folder: " + projectDir); // NOI18N } @@ -78,7 +81,11 @@ static Set createProject(File projectDir) throws IOException { if (dir == null) { throw new IOException("Cannot map folder to the file system: " + projectDir); // NOI18N } - GenericProject.createMarker(dir); + if (localMarker) { + GenericProject.setMarkerState(dir, false); + } else { + GenericProject.createMarker(dir); + } return Set.of(dir); } @@ -101,6 +108,7 @@ public void initialize(WizardDescriptor wiz) { public void uninitialize(WizardDescriptor wiz) { this.wiz.putProperty("projdir", null); // NOI18N this.wiz.putProperty("name", null); // NOI18N + this.wiz.putProperty("localMarker", null); // NOI18N this.wiz = null; panels = null; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java index 6b165ac092c1..06fdac89406f 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java @@ -24,6 +24,7 @@ import javax.swing.GroupLayout; import javax.swing.GroupLayout.Alignment; import javax.swing.JButton; +import javax.swing.JCheckBox; import javax.swing.JComponent; import javax.swing.JFileChooser; import javax.swing.JLabel; @@ -52,6 +53,10 @@ "LBL_CreatedFolder=Project &Folder:", "LBL_Browse=Br&owse...", "LBL_BrowseDialogTitle=Select Project Location", + "LBL_LocalMarker=Keep repository &clean (store marker locally)", + "TT_LocalMarker=" + + "

The project marker is stored in the IDE's user directory instead of the .nbgp file in the project folder.


" + + "

The project is then only recognized by this IDE installation at this location.

", "MSG_DefaultProjectName=MyGenericProject", "ERR_InvalidName=Project Name is not a valid folder name.", "ERR_InvalidPath=Project Folder is not a valid path.", @@ -70,6 +75,7 @@ final class GpWizardPanel implements WizardDescriptor.Panel, private final JTextField projectLocation = new JTextField(); private final JTextField createdFolder = new JTextField(); private final JButton browseButton = new JButton(); + private final JCheckBox localMarker = new JCheckBox(); private final ChangeSupport changeSupport = new ChangeSupport(this); @Override @@ -119,6 +125,7 @@ public void readSettings(WizardDescriptor settings) { String name = (String) wiz.getProperty("name"); // NOI18N projectName.setText(name == null ? Bundle.MSG_DefaultProjectName() : name); projectName.selectAll(); + localMarker.setSelected(false); updateCreatedFolder(); } @@ -126,6 +133,7 @@ public void readSettings(WizardDescriptor settings) { public void storeSettings(WizardDescriptor settings) { settings.putProperty("name", projectName.getText().strip()); // NOI18N settings.putProperty("projdir", new File(createdFolder.getText().strip())); // NOI18N + settings.putProperty("localMarker", localMarker.isSelected()); // NOI18N } @Override @@ -181,6 +189,8 @@ private JComponent createComponent() { createdFolder.setEditable(false); Mnemonics.setLocalizedText(browseButton, Bundle.LBL_Browse()); browseButton.addActionListener(e -> browse()); + Mnemonics.setLocalizedText(localMarker, Bundle.LBL_LocalMarker()); + localMarker.setToolTipText(Bundle.TT_LocalMarker()); // Making all labels stretchable snaps the shorter ones to // the widest label, so the fields start at the same x and @@ -205,6 +215,9 @@ private JComponent createComponent() { .addComponent(browseButton)) .addComponent(createdFolder, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE)) .addContainerGap()) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addComponent(localMarker)) ); layout.setVerticalGroup( layout.createParallelGroup(Alignment.LEADING) @@ -221,6 +234,8 @@ private JComponent createComponent() { .addGroup(layout.createParallelGroup(Alignment.BASELINE) .addComponent(folderLabel) .addComponent(createdFolder)) + .addGap(18, 18, 18) + .addComponent(localMarker) .addGap(0, 0, Short.MAX_VALUE) ) ); diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java index f70baf14bd3d..60186d231a47 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java @@ -19,8 +19,12 @@ package org.netbeans.modules.generic.project.ui.customizer; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.swing.GroupLayout; import javax.swing.GroupLayout.Alignment; +import javax.swing.JCheckBox; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; @@ -30,6 +34,7 @@ import org.netbeans.api.project.ProjectInformation; import org.netbeans.modules.generic.project.GenericProject; import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.openide.filesystems.FileObject; import org.openide.util.Lookup; import org.openide.util.NbBundle.Messages; @@ -45,6 +50,10 @@ "LBL_General=General", "LBL_Name=Name:", "LBL_Location=Location:", + "LBL_MarkerFile=Marker file in project directory", + "TT_MarkerFile=" + + "

When unchecked, the .nbgp marker file is removed from the project directory.

" + + "

The project becomes a local (private) project.

", }) public final class GpCompositeCategoryProvider implements ProjectCustomizer.CompositeCategoryProvider { @@ -70,13 +79,17 @@ public ProjectCustomizer.Category createCategory(Lookup context) { public JComponent createComponent(ProjectCustomizer.Category category, Lookup context) { Project prj = context.lookup(Project.class); ProjectInformation info = prj != null ? prj.getLookup().lookup(ProjectInformation.class) : null; + FileObject projectDir = prj != null ? prj.getProjectDirectory() : null; JLabel nameLabel = new JLabel(Bundle.LBL_Name()); JLabel locationLabel = new JLabel(Bundle.LBL_Location()); JTextField nameField = new JTextField(info != null ? info.getName() : ""); - JTextField locationField = new JTextField(prj != null ? prj.getProjectDirectory().getPath() : ""); + JTextField locationField = new JTextField(projectDir != null ? projectDir.getPath() : ""); nameField.setEditable(false); locationField.setEditable(false); + JCheckBox markerBox = new JCheckBox(Bundle.LBL_MarkerFile()); + markerBox.setToolTipText(Bundle.TT_MarkerFile()); + markerBox.setSelected(projectDir != null && GenericProject.hasMarker(projectDir)); JPanel panel = new JPanel(); GroupLayout layout = new GroupLayout(panel); @@ -93,6 +106,9 @@ public JComponent createComponent(ProjectCustomizer.Category category, Lookup co .addComponent(nameField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) .addComponent(locationField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE)) .addContainerGap()) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addComponent(markerBox)) ); layout.setVerticalGroup( layout.createParallelGroup(Alignment.LEADING) @@ -104,8 +120,21 @@ public JComponent createComponent(ProjectCustomizer.Category category, Lookup co .addGroup(layout.createParallelGroup(Alignment.BASELINE) .addComponent(locationLabel) .addComponent(locationField)) + .addGap(18, 18, 18) + .addComponent(markerBox) .addGap(0, 0, Short.MAX_VALUE)) ); + if (projectDir != null) { + // invoked on OK, off the AWT thread, in ProjectManager write access + category.setStoreListener(evt -> { + try { + GenericProject.setMarkerState(projectDir, markerBox.isSelected()); + } catch (IOException ex) { + Logger.getLogger(GpCompositeCategoryProvider.class.getName()) + .log(Level.WARNING, "Cannot change marker state", ex); + } + }); + } return panel; } diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index deddb60c76d3..c4934f9222eb 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -54,6 +54,12 @@ public void testMarkerFileIsProject() throws Exception { assertTrue(new GenericProject.GpFactory().isProject(root)); } + public void testAttributeOnlyIsProject() throws Exception { + FileObject root = newProjectDir(); + root.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); + assertTrue(new GenericProject.GpFactory().isProject(root)); + } + public void testLogicalViewShowsAllFiles() throws Exception { FileObject root = newProjectDir(); FileUtil.createData(root, GenericProject.MARKER_FILE); @@ -91,7 +97,7 @@ public void testSubprojectsAreFirstLevelOnly() throws Exception { public void testCreateProject() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N - Set created = GpWizardIterator.createProject(dir); + Set created = GpWizardIterator.createProject(dir, false); assertEquals(1, created.size()); FileObject dirFo = created.iterator().next(); FileObject marker = dirFo.getFileObject(GenericProject.MARKER_FILE); @@ -102,6 +108,46 @@ public void testCreateProject() throws Exception { assertTrue(prj instanceof GenericProject); } + public void testCreateProjectLocal() throws Exception { + clearWorkDir(); + File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N + Set created = GpWizardIterator.createProject(dir, true); + assertEquals(1, created.size()); + FileObject dirFo = created.iterator().next(); + assertNull(dirFo.getFileObject(GenericProject.MARKER_FILE)); + assertEquals(Boolean.TRUE, dirFo.getAttribute(GenericProject.MARKER_ATTRIBUTE)); + Project prj = ProjectManager.getDefault().findProject(dirFo); + assertNotNull(prj); + assertTrue(prj instanceof GenericProject); + } + + public void testSetMarkerStateLocalAndBack() throws Exception { + FileObject root = newProjectDir(); + GenericProject.createMarker(root); + assertTrue(GenericProject.hasMarker(root)); + + GenericProject.setMarkerState(root, false); + assertFalse(GenericProject.hasMarker(root)); + assertEquals(Boolean.TRUE, root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); + assertTrue(new GenericProject.GpFactory().isProject(root)); + + GenericProject.setMarkerState(root, true); + assertTrue(GenericProject.hasMarker(root)); + assertNull(root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); + } + + public void testSaveProjectLocalKeepsNoMarker() throws Exception { + clearWorkDir(); + File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N + GpWizardIterator.createProject(dir, true); + FileObject root = FileUtil.toFileObject(dir); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + new GenericProject.GpFactory().saveProject(prj); + assertNull(root.getFileObject(GenericProject.MARKER_FILE)); + assertEquals(Boolean.TRUE, root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N From 2f402e4e89aef45b9a3f082aa2ee88d0dacf89b2 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 19 Oct 2026 18:40:05 +0200 Subject: [PATCH 08/15] [action] common default actions + copy, move, delete + rename --- .../generic/project/GenericProject.java | 122 +++++++++- .../generic/project/GpActionProvider.java | 78 +++++++ .../project/GpLogicalViewProvider.java | 84 ++++++- .../generic/project/GpProjectOperations.java | 162 +++++++++++++ .../modules/generic/project/layer.xml | 64 ++++++ .../GpCompositeCategoryProvider.java | 15 +- .../generic/project/GenericProjectTest.java | 215 ++++++++++++++++++ 7 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 454e0475d8af..a0379ef7588e 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -20,10 +20,13 @@ package org.netbeans.modules.generic.project; import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; import java.io.IOException; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.Set; +import java.util.prefs.Preferences; import javax.swing.Icon; import javax.swing.UIManager; import javax.swing.event.ChangeListener; @@ -31,10 +34,12 @@ import org.netbeans.api.project.FileOwnerQuery; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; +import org.netbeans.api.project.ProjectUtils; import org.netbeans.modules.generic.project.ui.customizer.GpCustomizerProvider; import org.netbeans.spi.project.ProjectFactory; import org.netbeans.spi.project.ProjectState; import org.netbeans.spi.project.SubprojectProvider; +import org.netbeans.spi.project.support.LookupProviderSupport; import org.openide.filesystems.FileObject; import org.openide.util.ChangeSupport; import org.openide.util.ImageUtilities; @@ -60,9 +65,17 @@ public final class GenericProject implements Project { public static final String MARKER_ATTRIBUTE = "org.netbeans.modules.generic.project"; // NOI18N /** - * The project type key used for this project type's contributions - * in the layer filesystem - * (e.g. {@code Projects/org-netbeans-modules-generic-project/Customizer}). + * The project type key under which this project type is registered + * in the layer filesystem: + *
    + *
  • {@code Projects//Actions} — the stock actions of the + * project node's popup menu (loaded via + * {@code CommonProjectActions.forType}),
  • + *
  • {@code Projects//Lookup} — services contributed via + * {@code @ProjectServiceProvider} / {@code LookupProvider}, + * mixed into the project's lookup (see + * {@code LookupProviderSupport}).
  • + *
*/ public static final String PROJECT_KEY = "org-netbeans-modules-generic-project"; // NOI18N @@ -72,15 +85,33 @@ public final class GenericProject implements Project { private final FileObject projectDirectory; private final Lookup lookup; + /** + * Whether this project instance has been explicitly deleted + * (stock delete operation). Set before the marker file and the + * recognition attribute are removed so that + * {@link GpFactory#saveProject} never resurrects the project + * (e.g. when a preferences flush saves it afterwards). + */ + private volatile boolean deleted; + + void markDeleted() { + deleted = true; + } + private GenericProject(FileObject projectDirectory) { this.projectDirectory = projectDirectory; - this.lookup = Lookups.fixed( + // mix the project's own services with the layer contributions + // registered for this project type ("Projects//Lookup", + // e.g. via @ProjectServiceProvider in other modules) + this.lookup = LookupProviderSupport.createCompositeLookup(Lookups.fixed( new GpLogicalViewProvider(this), new GpInfo(this), new GpSubprojectProvider(this), new GpCustomizerProvider(this), + new GpProjectOperations(this), + new GpActionProvider(this), this - ); + ), "Projects/" + PROJECT_KEY + "/Lookup"); // NOI18N } @Override @@ -155,6 +186,55 @@ public static void setMarkerState(FileObject dir, boolean markerFile) throws IOE } } + /** + * Key of the display name override in the project's private + * preferences ({@link ProjectUtils#getPreferences}). + */ + static final String DISPLAY_NAME_KEY = "displayName"; // NOI18N + + /** + * Returns the display name override of the project, or + * {@code null} if none is stored, in which case the display name + * defaults to the project folder name. + */ + public static String getDisplayNameOverride(GenericProject prj) { + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, false); + if (prefs == null) { + return null; + } + String name = prefs.get(DISPLAY_NAME_KEY, null); + return (name == null || name.isBlank()) ? null : name; + } + + /** + * Stores the display name override of the project, or clears it + * for {@code null}. A name equal to the project folder name is + * treated as no override. + * + *

Fires {@link ProjectInformation#PROP_DISPLAY_NAME} on the + * project's {@link ProjectInformation} if the effective display + * name changes, so that the project view updates its label. + */ + public static void setDisplayNameOverride(GenericProject prj, String name) { + if (name != null && name.equals(prj.getProjectDirectory().getNameExt())) { + name = null; + } + if (Objects.equals(name, getDisplayNameOverride(prj))) { + return; + } + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, false); + if (prefs == null) { + return; + } + if (name == null) { + prefs.remove(DISPLAY_NAME_KEY); + } else { + prefs.put(DISPLAY_NAME_KEY, name); + } + GpInfo info = (GpInfo) prj.getLookup().lookup(ProjectInformation.class); + info.fireDisplayNameChanged(); + } + @ServiceProvider(service=ProjectFactory.class) public static final class GpFactory implements ProjectFactory { @@ -173,6 +253,11 @@ public Project loadProject(FileObject projectDirectory, ProjectState state) thro @Override public void saveProject(Project project) throws IOException, ClassCastException { + if (project instanceof GenericProject gp && gp.deleted) { + // the project was explicitly deleted: never + // resurrect the marker or the attribute + return; + } FileObject dir = project.getProjectDirectory(); if (isAttributeProject(dir)) { // local project: recognition lives in the FileObject @@ -185,7 +270,14 @@ public void saveProject(Project project) throws IOException, ClassCastException } - private record GpInfo(GenericProject prj) implements ProjectInformation { + private static final class GpInfo implements ProjectInformation { + + private final GenericProject prj; + private final PropertyChangeSupport changeSupport = new PropertyChangeSupport(this); + + GpInfo(GenericProject prj) { + this.prj = prj; + } @Override public String getName() { @@ -194,7 +286,8 @@ public String getName() { @Override public String getDisplayName() { - return prj.getProjectDirectory().getNameExt(); + String override = getDisplayNameOverride(prj); + return override != null ? override : prj.getProjectDirectory().getNameExt(); } @Override @@ -208,11 +301,22 @@ public Project getProject() { return prj; } + /** + * Notifies listeners that the display name changed. + */ + void fireDisplayNameChanged() { + changeSupport.firePropertyChange(PROP_DISPLAY_NAME, null, null); + } + @Override - public void addPropertyChangeListener(PropertyChangeListener listener) {} + public void addPropertyChangeListener(PropertyChangeListener listener) { + changeSupport.addPropertyChangeListener(listener); + } @Override - public void removePropertyChangeListener(PropertyChangeListener listener) {} + public void removePropertyChangeListener(PropertyChangeListener listener) { + changeSupport.removePropertyChangeListener(listener); + } } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java new file mode 100644 index 000000000000..b8fc56bcef6c --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java @@ -0,0 +1,78 @@ +/* + * 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.generic.project; + +import org.netbeans.spi.project.ActionProvider; +import org.netbeans.spi.project.support.ProjectOperations; +import org.netbeans.spi.project.ui.support.DefaultProjectOperations; +import org.openide.util.Lookup; + +/** + * Advertises the default project operations of generic projects so + * that the standard project actions ("Rename...", "Move...", + * "Copy...", "Delete") become available in the project's popup + * menu. + * + *

The operations are delegated to the default implementations, + * which show the standard confirmation dialogs and update the + * project through the {@link GpProjectOperations} registered in + * the lookup. + */ +final class GpActionProvider implements ActionProvider { + + private final GenericProject prj; + + GpActionProvider(GenericProject prj) { + this.prj = prj; + } + + @Override + public String[] getSupportedActions() { + return new String[] { + ActionProvider.COMMAND_RENAME, + ActionProvider.COMMAND_MOVE, + ActionProvider.COMMAND_COPY, + ActionProvider.COMMAND_DELETE, + }; + } + + @Override + public void invokeAction(String command, Lookup context) throws IllegalArgumentException { + switch (command) { + case ActionProvider.COMMAND_RENAME -> DefaultProjectOperations.performDefaultRenameOperation(prj, null); + case ActionProvider.COMMAND_MOVE -> DefaultProjectOperations.performDefaultMoveOperation(prj); + case ActionProvider.COMMAND_COPY -> DefaultProjectOperations.performDefaultCopyOperation(prj); + case ActionProvider.COMMAND_DELETE -> DefaultProjectOperations.performDefaultDeleteOperation(prj); + default -> throw new IllegalArgumentException(command); + } + } + + @Override + public boolean isActionEnabled(String command, Lookup context) throws IllegalArgumentException { + return switch (command) { + case ActionProvider.COMMAND_RENAME -> ProjectOperations.isMoveOperationSupported(prj); + case ActionProvider.COMMAND_MOVE -> ProjectOperations.isMoveOperationSupported(prj); + case ActionProvider.COMMAND_COPY -> ProjectOperations.isCopyOperationSupported(prj); + case ActionProvider.COMMAND_DELETE -> ProjectOperations.isDeleteOperationSupported(prj); + default -> throw new IllegalArgumentException(command); + }; + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java index f77708484603..8fdb5d4417da 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java @@ -20,24 +20,41 @@ package org.netbeans.modules.generic.project; import java.awt.Image; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.swing.Action; +import org.netbeans.api.project.ProjectInformation; +import org.netbeans.api.project.ProjectUtils; import org.netbeans.spi.project.ui.LogicalViewProvider; import org.netbeans.spi.project.ui.support.CommonProjectActions; import org.openide.filesystems.FileObject; import org.openide.loaders.DataFilter; +import org.openide.loaders.DataObject; +import org.openide.loaders.DataObjectNotFoundException; import org.openide.loaders.DataFolder; import org.openide.nodes.AbstractNode; import org.openide.nodes.Children; import org.openide.nodes.Node; import org.openide.util.ImageUtilities; -import org.openide.util.lookup.Lookups; +import org.openide.util.lookup.AbstractLookup; +import org.openide.util.lookup.InstanceContent; /** * The logical view of a generic project simply shows all files contained * in the project directory. + * + *

The root node exposes the project, the project directory + * {@link FileObject} and the directory's {@link DataObject} in its + * lookup, which is what the stock project actions rely on (e.g. + * "New File" uses the {@link DataObject} to determine the target + * folder). Its action set is the standard one, registered in the layer + * under {@code Projects//Actions} and loaded via + * {@link CommonProjectActions#forType}. */ public record GpLogicalViewProvider(GenericProject project) implements LogicalViewProvider { + private static final Logger LOG = Logger.getLogger(GpLogicalViewProvider.class.getName()); // NOI18N + @Override public Node createLogicalView() { FileObject dir = project.getProjectDirectory(); @@ -47,14 +64,33 @@ public Node createLogicalView() { } catch (Exception ex) { children = Children.LEAF; } - AbstractNode base = new AbstractNode(children, Lookups.fixed(project, dir)) { + ProjectInformation info = ProjectUtils.getInformation(project); + InstanceContent content = new InstanceContent(); + AbstractNode base = new AbstractNode(children, new AbstractLookup(content)) { + { + // keep the tree label in sync with the display name + // override (rename without renaming the folder) + info.addPropertyChangeListener(evt -> { + if (ProjectInformation.PROP_DISPLAY_NAME.equals(evt.getPropertyName())) { + fireDisplayNameChange(null, null); + } + }); + } + + @Override + public String getDisplayName() { + // honors the display name override (rename without + // renaming the folder) + return info.getDisplayName(); + } + @Override public Action[] getActions(boolean context) { - return new Action[] { - CommonProjectActions.openSubprojectsAction(), - CommonProjectActions.closeProjectAction(), - CommonProjectActions.customizeProjectAction(), - }; + // the stock actions registered in the layer under + // "Projects//Actions" (shadows of the + // stock project actions plus the shared + // "Projects/Actions" folder, e.g. versioning) + return CommonProjectActions.forType(GenericProject.PROJECT_KEY); } @Override @@ -67,7 +103,39 @@ public Image getOpenedIcon(int type) { return getIcon(type); } }; - base.setDisplayName(dir.getNameExt()); + content.add(project); + content.add(dir); + // also expose the DataObject of the project directory: stock + // actions (e.g. "New File") use it to determine the target + // folder + content.add(project, new InstanceContent.Convertor() { + @Override + public DataObject convert(GenericProject obj) { + try { + FileObject fo = obj.getProjectDirectory(); + return fo != null && fo.isValid() ? DataObject.find(fo) : null; + } catch (DataObjectNotFoundException ex) { + LOG.log(Level.WARNING, null, ex); + return null; + } + } + + @Override + public Class type(GenericProject obj) { + return DataObject.class; + } + + @Override + public String id(GenericProject obj) { + return dir.getPath(); + } + + @Override + public String displayName(GenericProject obj) { + return dir.getNameExt(); + } + }); + base.setName(dir.getNameExt()); return base; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java new file mode 100644 index 000000000000..436a5223d783 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java @@ -0,0 +1,162 @@ +/* + * 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.generic.project; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.CopyOperationImplementation; +import org.netbeans.spi.project.DeleteOperationImplementation; +import org.netbeans.spi.project.MoveOrRenameOperationImplementation; +import org.openide.filesystems.FileObject; + +/** + * Supports the default move, rename, copy and delete operations + * of generic projects. + * + *

The project is just a directory, so the marker file (when + * present) is the only metadata and everything contained in the + * directory is data. For move/rename nothing has to be updated: + * the directory — marker file and project attributes included — + * is relocated as a whole (the default move implementation does + * not use the data file list). For copy the stock flow duplicates + * the visible directory content as-is, so the new project needs + * no bookkeeping either. For delete the stock flow deletes the + * metadata, optionally the data, and finally the project folder + * if it is empty then, hence the data files are listed + * deepest-first. + * + *

A pure rename (the "Also rename project folder" option + * unchecked) does not touch the project directory; the new name + * is stored as a display name override, so the project's display + * name reports it while the programmatic name keeps the folder + * name. When the folder is renamed or moved, the (new) folder + * name is authoritative again and any override is dropped. + */ +final class GpProjectOperations implements MoveOrRenameOperationImplementation, CopyOperationImplementation, DeleteOperationImplementation { + + private final GenericProject prj; + + GpProjectOperations(GenericProject prj) { + this.prj = prj; + } + + @Override + public List getMetadataFiles() { + // The marker file (when present) is the only project + // metadata; local (attribute-based) projects keep their + // recognition in a FileObject attribute, which needs no file. + FileObject marker = prj.getProjectDirectory().getFileObject(GenericProject.MARKER_FILE); + return marker != null ? List.of(marker) : List.of(); + } + + @Override + public List getDataFiles() { + // The whole directory is the project, so all of its content + // is data. Listed deepest-first so that the stock delete + // flow (which deletes each entry and afterwards the project + // folder if it is empty) can remove everything. + List result = new ArrayList<>(); + collectDataFiles(prj.getProjectDirectory(), result); + return result; + } + + private void collectDataFiles(FileObject dir, List result) { + for (FileObject child : dir.getChildren()) { + if (child.isFolder()) { + collectDataFiles(child, result); + result.add(child); + } else if (dir.equals(prj.getProjectDirectory()) + && GenericProject.MARKER_FILE.equals(child.getName())) { + // the marker file is metadata, not data: the stock + // flow deletes it unconditionally + } else { + result.add(child); + } + } + } + + @Override + public void notifyMoving() throws IOException { + // Nothing to do: the marker file and the project attribute + // both follow the folder when it is moved. + } + + @Override + public void notifyMoved(Project original, File originalPath, String nueName) throws IOException { + if (original != null) { + // Called on the new project instance: the folder (and the + // attribute holding any display name override) has been + // renamed, so the new folder name is authoritative again. + GenericProject.setDisplayNameOverride(prj, nueName); + } + } + + @Override + public void notifyRenaming() throws IOException { + // Nothing to do. + } + + @Override + public void notifyRenamed(String nueName) throws IOException { + GenericProject.setDisplayNameOverride(prj, nueName); + } + + @Override + public void notifyCopying() throws IOException { + // Nothing to do: the stock flow copies the visible + // directory content as-is. + } + + @Override + public void notifyCopied(Project original, File originalPath, String nueName) throws IOException { + if (original != null) { + // Called on the new project instance: keep the folder + // name authoritative, like after a move. In practice + // this is a no-op — the display name override lives in + // per-project preferences keyed by location, which the + // copy does not inherit. + GenericProject.setDisplayNameOverride(prj, nueName); + } + } + + @Override + public void notifyDeleting() throws IOException { + // Remember that this project has been explicitly deleted so + // that saveProject (e.g. from a preferences flush) never + // resurrects it, and drop the recognition attribute of local + // (attribute-based) projects so the folder stops being a + // project even when it survives the delete. + prj.markDeleted(); + FileObject dir = prj.getProjectDirectory(); + if (GenericProject.isAttributeProject(dir)) { + dir.setAttribute(GenericProject.MARKER_ATTRIBUTE, null); + } + } + + @Override + public void notifyDeleted() throws IOException { + // Nothing to do: the stock flow has already deleted the + // listed files and the (now empty) project folder. + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml index 480e9e1ee993..5bc99c7d4fea 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml @@ -28,4 +28,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java index 60186d231a47..7f071a54440e 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java @@ -49,6 +49,9 @@ @Messages({ "LBL_General=General", "LBL_Name=Name:", + "TT_Name=" + + "

The name of the project as shown in the project view.

" + + "

Changing it does not rename the project folder. Enter the folder name to reset.

", "LBL_Location=Location:", "LBL_MarkerFile=Marker file in project directory", "TT_MarkerFile=" @@ -83,9 +86,9 @@ public JComponent createComponent(ProjectCustomizer.Category category, Lookup co JLabel nameLabel = new JLabel(Bundle.LBL_Name()); JLabel locationLabel = new JLabel(Bundle.LBL_Location()); - JTextField nameField = new JTextField(info != null ? info.getName() : ""); + JTextField nameField = new JTextField(info != null ? info.getDisplayName() : ""); + nameField.setToolTipText(Bundle.TT_Name()); JTextField locationField = new JTextField(projectDir != null ? projectDir.getPath() : ""); - nameField.setEditable(false); locationField.setEditable(false); JCheckBox markerBox = new JCheckBox(Bundle.LBL_MarkerFile()); markerBox.setToolTipText(Bundle.TT_MarkerFile()); @@ -124,14 +127,18 @@ public JComponent createComponent(ProjectCustomizer.Category category, Lookup co .addComponent(markerBox) .addGap(0, 0, Short.MAX_VALUE)) ); - if (projectDir != null) { + if (prj != null && projectDir != null) { // invoked on OK, off the AWT thread, in ProjectManager write access category.setStoreListener(evt -> { try { GenericProject.setMarkerState(projectDir, markerBox.isSelected()); + String name = nameField.getText().strip(); + if (!name.isEmpty()) { + GenericProject.setDisplayNameOverride((GenericProject) prj, name); + } } catch (IOException ex) { Logger.getLogger(GpCompositeCategoryProvider.class.getName()) - .log(Level.WARNING, "Cannot change marker state", ex); + .log(Level.WARNING, "Cannot update project settings", ex); } }); } diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index c4934f9222eb..28a5239d14b5 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -20,14 +20,22 @@ package org.netbeans.modules.generic.project; import java.io.File; +import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; import org.netbeans.api.project.ProjectManager; +import org.netbeans.api.project.ProjectUtils; import org.netbeans.junit.NbTestCase; +import org.netbeans.spi.project.ActionProvider; +import org.netbeans.spi.project.ProjectServiceProvider; import org.netbeans.spi.project.SubprojectProvider; import org.netbeans.spi.project.ui.LogicalViewProvider; import org.openide.filesystems.FileObject; import org.openide.filesystems.FileUtil; +import org.openide.loaders.DataObject; import org.openide.nodes.Node; import org.openide.util.test.MockLookup; @@ -148,6 +156,206 @@ public void testSaveProjectLocalKeepsNoMarker() throws Exception { assertEquals(Boolean.TRUE, root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); } + public void testDisplayNameOverrideRoundTrip() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + ProjectInformation info = ProjectUtils.getInformation(prj); + assertEquals(root.getNameExt(), info.getName()); + assertEquals(root.getNameExt(), info.getDisplayName()); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + assertEquals(root.getNameExt(), info.getName()); + assertEquals("My Fancy Project", info.getDisplayName()); + + GenericProject.setDisplayNameOverride((GenericProject) prj, null); + assertEquals(root.getNameExt(), info.getDisplayName()); + } + + public void testDisplayNameOverrideFiresPropertyChange() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + ProjectInformation info = ProjectUtils.getInformation(prj); + + AtomicReference fired = new AtomicReference<>(); + info.addPropertyChangeListener(evt -> fired.compareAndSet(null, evt.getPropertyName())); + GenericProject.setDisplayNameOverride((GenericProject) prj, "New Name"); + assertEquals(ProjectInformation.PROP_DISPLAY_NAME, fired.get()); + } + + public void testRenameOperationStoresOverride() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GpProjectOperations op = prj.getLookup().lookup(GpProjectOperations.class); + assertNotNull(op); + + op.notifyRenamed("Renamed Project"); + assertEquals("Renamed Project", ProjectUtils.getInformation(prj).getDisplayName()); + assertEquals(root.getNameExt(), ProjectUtils.getInformation(prj).getName()); + } + + public void testMovedClearsStaleOverride() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GpProjectOperations op = prj.getLookup().lookup(GpProjectOperations.class); + assertNotNull(op); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "Old Override"); + assertEquals("Old Override", ProjectUtils.getInformation(prj).getDisplayName()); + + // notification on the new project instance (original != null): + // the new folder name is authoritative again + op.notifyMoved(prj, new File("/tmp/does-not-matter"), root.getNameExt()); // NOI18N + assertEquals(root.getNameExt(), ProjectUtils.getInformation(prj).getDisplayName()); + } + + public void testLogicalViewNodeHonorsDisplayNameOverride() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + Node node = prj.getLookup().lookup(LogicalViewProvider.class).createLogicalView(); + assertEquals(root.getNameExt(), node.getDisplayName()); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "Fancy Name"); + assertEquals("Fancy Name", node.getDisplayName()); + + GenericProject.setDisplayNameOverride((GenericProject) prj, null); + assertEquals(root.getNameExt(), node.getDisplayName()); + } + + public void testActionProviderSupportsRename() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + ActionProvider ap = prj.getLookup().lookup(ActionProvider.class); + assertNotNull(ap); + assertTrue(List.of(ap.getSupportedActions()).contains(ActionProvider.COMMAND_RENAME)); + assertTrue(ap.isActionEnabled(ActionProvider.COMMAND_RENAME, null)); + assertTrue(List.of(ap.getSupportedActions()).contains(ActionProvider.COMMAND_DELETE)); + assertTrue(ap.isActionEnabled(ActionProvider.COMMAND_DELETE, null)); + assertTrue(List.of(ap.getSupportedActions()).contains(ActionProvider.COMMAND_MOVE)); + assertTrue(ap.isActionEnabled(ActionProvider.COMMAND_MOVE, null)); + assertTrue(List.of(ap.getSupportedActions()).contains(ActionProvider.COMMAND_COPY)); + assertTrue(ap.isActionEnabled(ActionProvider.COMMAND_COPY, null)); + } + + public void testDeleteOperationFiles() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileUtil.createData(root, "readme.txt"); + FileObject sub = FileUtil.createFolder(root, "src"); + FileUtil.createData(sub, "Main.java"); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GpProjectOperations op = prj.getLookup().lookup(GpProjectOperations.class); + assertNotNull(op); + + assertEquals(List.of(root.getFileObject(GenericProject.MARKER_FILE)), op.getMetadataFiles()); + + // all of the directory's content is data, the marker file + // excluded (it is metadata), deepest-first so that the + // stock delete flow can delete every entry and the + // (now empty) project folder + FileObject readme = root.getFileObject("readme.txt"); // NOI18N + FileObject main = sub.getFileObject("Main.java"); // NOI18N + List data = op.getDataFiles(); + assertEquals(new HashSet<>(List.of(readme, main, sub)), new HashSet<>(data)); + assertTrue("children must be listed before their parent", data.indexOf(main) < data.indexOf(sub)); + } + + public void testDeleteOperationPreventsMarkerResurrection() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GpProjectOperations op = prj.getLookup().lookup(GpProjectOperations.class); + assertNotNull(op); + + op.notifyDeleting(); + root.getFileObject(GenericProject.MARKER_FILE).delete(); // simulate the stock flow + + new GenericProject.GpFactory().saveProject(prj); + assertNull("a saved deleted project must not be resurrected", root.getFileObject(GenericProject.MARKER_FILE)); + } + + public void testDeleteOperationClearsAttribute() throws Exception { + FileObject root = newProjectDir(); // local (attribute-based) project + root.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GpProjectOperations op = prj.getLookup().lookup(GpProjectOperations.class); + assertNotNull(op); + + op.notifyDeleting(); + + assertNull("the recognition attribute must be dropped", root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); + new GenericProject.GpFactory().saveProject(prj); + assertNull("a saved deleted local project must not gain a marker file", root.getFileObject(GenericProject.MARKER_FILE)); + } + + public void testCopyOperationKeepsFolderNameAuthoritative() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + + // simulate the stock copy flow: duplicate the directory + // content, find the new project, and notify the operations + // of both projects + FileObject copy = FileUtil.createFolder(root.getParent(), "copy-" + getName()); // NOI18N + FileUtil.copyFile(root.getFileObject(GenericProject.MARKER_FILE), copy, GenericProject.MARKER_FILE, null); + Project nue = ProjectManager.getDefault().findProject(copy); + assertNotNull(nue); + GpProjectOperations opOrig = prj.getLookup().lookup(GpProjectOperations.class); + GpProjectOperations opNue = nue.getLookup().lookup(GpProjectOperations.class); + assertNotNull(opOrig); + assertNotNull(opNue); + opOrig.notifyCopied(null, FileUtil.toFile(root), copy.getNameExt()); + opNue.notifyCopied(prj, FileUtil.toFile(root), copy.getNameExt()); + + assertEquals("the folder name is authoritative on the copy", + copy.getNameExt(), ProjectUtils.getInformation(nue).getDisplayName()); + assertEquals("the original keeps its display name", + "My Fancy Project", ProjectUtils.getInformation(prj).getDisplayName()); + } + + public void testLogicalViewNodeLookupExposesProjectAndDirectory() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + Node node = prj.getLookup().lookup(LogicalViewProvider.class).createLogicalView(); + assertEquals(prj, node.getLookup().lookup(GenericProject.class)); + assertEquals(root, node.getLookup().lookup(FileObject.class)); + DataObject dobj = node.getLookup().lookup(DataObject.class); + assertNotNull("the project directory DataObject must be in the node lookup", dobj); + assertEquals(root, dobj.getPrimaryFile()); + } + + public void testProjectLookupContainsLayerServices() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + // TestMarkerProvider is registered for this project type via + // @ProjectServiceProvider, i.e. as a layer contribution under + // "Projects//Lookup": the composite project lookup must + // mix it in + assertNotNull(prj.getLookup().lookup(TestMarker.class)); + assertNotNull("the project's own services must still be present", + prj.getLookup().lookup(ProjectInformation.class)); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N @@ -155,4 +363,11 @@ private FileObject newProjectDir() throws Exception { return FileUtil.toFileObject(dir); } + public interface TestMarker { + } + + @ProjectServiceProvider(service=TestMarker.class, projectType=GenericProject.PROJECT_KEY) + public static class TestMarkerProvider implements TestMarker { + } + } From 0f6ec0deadaffb674a9f5b93861851ab4d87b9d7 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Tue, 20 Oct 2026 13:16:31 +0200 Subject: [PATCH 09/15] refactored into packages --- .../modules/generic/project/GenericProject.java | 8 +++++++- .../project/{ => ui/actions}/GpActionProvider.java | 7 ++++--- .../{ => ui/actions}/GpProjectOperations.java | 8 +++++--- .../{ => ui/wizard}/GpTemplateDescription.html | 0 .../project/{ => ui/wizard}/GpWizardIterator.java | 13 ++++++------- .../project/{ => ui/wizard}/GpWizardPanel.java | 2 +- .../modules/generic/project/GenericProjectTest.java | 2 ++ 7 files changed, 25 insertions(+), 15 deletions(-) rename ide/generic.project/src/org/netbeans/modules/generic/project/{ => ui/actions}/GpActionProvider.java (93%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{ => ui/actions}/GpProjectOperations.java (95%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{ => ui/wizard}/GpTemplateDescription.html (100%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{ => ui/wizard}/GpWizardIterator.java (92%) rename ide/generic.project/src/org/netbeans/modules/generic/project/{ => ui/wizard}/GpWizardPanel.java (99%) diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index a0379ef7588e..45a4b5b88c6b 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -35,6 +35,8 @@ import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.api.project.ProjectUtils; +import org.netbeans.modules.generic.project.ui.actions.GpActionProvider; +import org.netbeans.modules.generic.project.ui.actions.GpProjectOperations; import org.netbeans.modules.generic.project.ui.customizer.GpCustomizerProvider; import org.netbeans.spi.project.ProjectFactory; import org.netbeans.spi.project.ProjectState; @@ -94,7 +96,11 @@ public final class GenericProject implements Project { */ private volatile boolean deleted; - void markDeleted() { + /** + * Marks this project instance as explicitly deleted, see + * {@link #deleted}. + */ + public void markDeleted() { deleted = true; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpActionProvider.java similarity index 93% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpActionProvider.java index b8fc56bcef6c..600a4328ea4a 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpActionProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpActionProvider.java @@ -17,8 +17,9 @@ * under the License. */ -package org.netbeans.modules.generic.project; +package org.netbeans.modules.generic.project.ui.actions; +import org.netbeans.modules.generic.project.GenericProject; import org.netbeans.spi.project.ActionProvider; import org.netbeans.spi.project.support.ProjectOperations; import org.netbeans.spi.project.ui.support.DefaultProjectOperations; @@ -35,11 +36,11 @@ * project through the {@link GpProjectOperations} registered in * the lookup. */ -final class GpActionProvider implements ActionProvider { +public final class GpActionProvider implements ActionProvider { private final GenericProject prj; - GpActionProvider(GenericProject prj) { + public GpActionProvider(GenericProject prj) { this.prj = prj; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java similarity index 95% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java index 436a5223d783..68b62260629c 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpProjectOperations.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java @@ -17,7 +17,9 @@ * under the License. */ -package org.netbeans.modules.generic.project; +package org.netbeans.modules.generic.project.ui.actions; + +import org.netbeans.modules.generic.project.GenericProject; import java.io.File; import java.io.IOException; @@ -52,11 +54,11 @@ * name. When the folder is renamed or moved, the (new) folder * name is authoritative again and any override is dropped. */ -final class GpProjectOperations implements MoveOrRenameOperationImplementation, CopyOperationImplementation, DeleteOperationImplementation { +public final class GpProjectOperations implements MoveOrRenameOperationImplementation, CopyOperationImplementation, DeleteOperationImplementation { private final GenericProject prj; - GpProjectOperations(GenericProject prj) { + public GpProjectOperations(GenericProject prj) { this.prj = prj; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpTemplateDescription.html b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpTemplateDescription.html similarity index 100% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GpTemplateDescription.html rename to ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpTemplateDescription.html diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardIterator.java similarity index 92% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardIterator.java index 7b18eadf1fa2..686a7623460c 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardIterator.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardIterator.java @@ -17,7 +17,7 @@ * under the License. */ -package org.netbeans.modules.generic.project; +package org.netbeans.modules.generic.project.ui.wizard; import java.io.File; import java.io.IOException; @@ -27,6 +27,7 @@ import javax.swing.JComponent; import javax.swing.event.ChangeListener; import org.netbeans.api.templates.TemplateRegistration; +import org.netbeans.modules.generic.project.GenericProject; import org.netbeans.spi.project.ui.support.ProjectChooser; import org.openide.WizardDescriptor; import org.openide.filesystems.FileObject; @@ -73,7 +74,7 @@ public Set instantiate() throws IOException { * for {@code localMarker} — marks it as a local project by the * FileObject attribute only. */ - static Set createProject(File projectDir, boolean localMarker) throws IOException { + public static Set createProject(File projectDir, boolean localMarker) throws IOException { if (!projectDir.exists() && !projectDir.mkdirs()) { throw new IOException("Cannot create folder: " + projectDir); // NOI18N } @@ -81,11 +82,9 @@ static Set createProject(File projectDir, boolean localMarker) throw if (dir == null) { throw new IOException("Cannot map folder to the file system: " + projectDir); // NOI18N } - if (localMarker) { - GenericProject.setMarkerState(dir, false); - } else { - GenericProject.createMarker(dir); - } + // local project: recognition attribute only, + // otherwise the marker file in the working tree + GenericProject.setMarkerState(dir, !localMarker); return Set.of(dir); } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardPanel.java similarity index 99% rename from ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java rename to ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardPanel.java index 06fdac89406f..8cf571019774 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpWizardPanel.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/wizard/GpWizardPanel.java @@ -17,7 +17,7 @@ * under the License. */ -package org.netbeans.modules.generic.project; +package org.netbeans.modules.generic.project.ui.wizard; import java.awt.Component; import java.io.File; diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index 28a5239d14b5..3daf60fdbf1c 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -29,6 +29,8 @@ import org.netbeans.api.project.ProjectManager; import org.netbeans.api.project.ProjectUtils; import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.generic.project.ui.actions.GpProjectOperations; +import org.netbeans.modules.generic.project.ui.wizard.GpWizardIterator; import org.netbeans.spi.project.ActionProvider; import org.netbeans.spi.project.ProjectServiceProvider; import org.netbeans.spi.project.SubprojectProvider; From 9ec34975486ab9609f4ef8fc64247f836d75e41f Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Tue, 20 Oct 2026 14:05:03 +0200 Subject: [PATCH 10/15] refactored base package --- .../generic/project/GenericProject.java | 151 ++---------------- .../modules/generic/project/GpFactory.java | 66 ++++++++ .../modules/generic/project/GpInfo.java | 83 ++++++++++ .../generic/project/GpSubprojectProvider.java | 71 ++++++++ .../generic/project/GenericProjectTest.java | 14 +- 5 files changed, 236 insertions(+), 149 deletions(-) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpFactory.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpInfo.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpSubprojectProvider.java diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 45a4b5b88c6b..388e21afc682 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -19,35 +19,20 @@ package org.netbeans.modules.generic.project; -import java.beans.PropertyChangeListener; -import java.beans.PropertyChangeSupport; import java.io.IOException; -import java.util.Collections; -import java.util.LinkedHashSet; import java.util.Objects; -import java.util.Set; import java.util.prefs.Preferences; -import javax.swing.Icon; -import javax.swing.UIManager; -import javax.swing.event.ChangeListener; import org.netbeans.api.annotations.common.StaticResource; -import org.netbeans.api.project.FileOwnerQuery; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.api.project.ProjectUtils; import org.netbeans.modules.generic.project.ui.actions.GpActionProvider; import org.netbeans.modules.generic.project.ui.actions.GpProjectOperations; import org.netbeans.modules.generic.project.ui.customizer.GpCustomizerProvider; -import org.netbeans.spi.project.ProjectFactory; -import org.netbeans.spi.project.ProjectState; -import org.netbeans.spi.project.SubprojectProvider; import org.netbeans.spi.project.support.LookupProviderSupport; import org.openide.filesystems.FileObject; -import org.openide.util.ChangeSupport; -import org.openide.util.ImageUtilities; import org.openide.util.Lookup; import org.openide.util.lookup.Lookups; -import org.openide.util.lookup.ServiceProvider; /** * A generic project. A directory is recognized as a generic project @@ -104,7 +89,15 @@ public void markDeleted() { deleted = true; } - private GenericProject(FileObject projectDirectory) { + /** + * Whether this project instance has been explicitly deleted, + * see {@link #deleted}. + */ + boolean isDeleted() { + return deleted; + } + + GenericProject(FileObject projectDirectory) { this.projectDirectory = projectDirectory; // mix the project's own services with the layer contributions // registered for this project type ("Projects//Lookup", @@ -240,130 +233,4 @@ public static void setDisplayNameOverride(GenericProject prj, String name) { GpInfo info = (GpInfo) prj.getLookup().lookup(ProjectInformation.class); info.fireDisplayNameChanged(); } - - @ServiceProvider(service=ProjectFactory.class) - public static final class GpFactory implements ProjectFactory { - - @Override - public boolean isProject(FileObject projectDirectory) { - return GenericProject.isProject(projectDirectory); - } - - @Override - public Project loadProject(FileObject projectDirectory, ProjectState state) throws IOException { - if (GenericProject.isProject(projectDirectory)) { - return new GenericProject(projectDirectory); - } - return null; - } - - @Override - public void saveProject(Project project) throws IOException, ClassCastException { - if (project instanceof GenericProject gp && gp.deleted) { - // the project was explicitly deleted: never - // resurrect the marker or the attribute - return; - } - FileObject dir = project.getProjectDirectory(); - if (isAttributeProject(dir)) { - // local project: recognition lives in the FileObject - // attribute, never (re)create the marker file - dir.setAttribute(MARKER_ATTRIBUTE, Boolean.TRUE); - } else { - createMarker(dir); - } - } - - } - - private static final class GpInfo implements ProjectInformation { - - private final GenericProject prj; - private final PropertyChangeSupport changeSupport = new PropertyChangeSupport(this); - - GpInfo(GenericProject prj) { - this.prj = prj; - } - - @Override - public String getName() { - return prj.getProjectDirectory().getNameExt(); - } - - @Override - public String getDisplayName() { - String override = getDisplayNameOverride(prj); - return override != null ? override : prj.getProjectDirectory().getNameExt(); - } - - @Override - public Icon getIcon() { - Icon icon = ImageUtilities.loadIcon(ICON); - return icon != null ? icon : UIManager.getIcon("Nb.Explorer.Folder.icon"); // NOI18N - } - - @Override - public Project getProject() { - return prj; - } - - /** - * Notifies listeners that the display name changed. - */ - void fireDisplayNameChanged() { - changeSupport.firePropertyChange(PROP_DISPLAY_NAME, null, null); - } - - @Override - public void addPropertyChangeListener(PropertyChangeListener listener) { - changeSupport.addPropertyChangeListener(listener); - } - - @Override - public void removePropertyChangeListener(PropertyChangeListener listener) { - changeSupport.removePropertyChangeListener(listener); - } - - } - - /** - * Exposes the projects contained directly in the project directory - * as subprojects. Adds "Open Required Projects" and "Open Selected Project" actions. - */ - private static final class GpSubprojectProvider implements SubprojectProvider { - - private final GenericProject prj; - private final ChangeSupport changeSupport = new ChangeSupport(this); - - GpSubprojectProvider(GenericProject prj) { - this.prj = prj; - } - - @Override - public Set getSubprojects() { - Set subprojects = new LinkedHashSet<>(); - FileObject[] children = prj.getProjectDirectory().getChildren(); - if (children != null) { - for (FileObject child : children) { - Project owner = FileOwnerQuery.getOwner(child); - if (owner != null && !owner.equals(prj)) { - subprojects.add(owner); - } - } - } - return Collections.unmodifiableSet(subprojects); - } - - @Override - public void addChangeListener(ChangeListener listener) { - changeSupport.addChangeListener(listener); - } - - @Override - public void removeChangeListener(ChangeListener listener) { - changeSupport.removeChangeListener(listener); - } - - } - } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpFactory.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpFactory.java new file mode 100644 index 000000000000..17e2c32fd95d --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpFactory.java @@ -0,0 +1,66 @@ +/* + * 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.generic.project; + +import java.io.IOException; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ProjectFactory; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.lookup.ServiceProvider; + +/** + * The {@link ProjectFactory} for generic projects: recognizes a + * directory by the marker file or the local-project recognition + * attribute and instantiates {@link GenericProject}. + */ +@ServiceProvider(service=ProjectFactory.class) +public final class GpFactory implements ProjectFactory { + + @Override + public boolean isProject(FileObject projectDirectory) { + return GenericProject.isProject(projectDirectory); + } + + @Override + public Project loadProject(FileObject projectDirectory, ProjectState state) throws IOException { + if (GenericProject.isProject(projectDirectory)) { + return new GenericProject(projectDirectory); + } + return null; + } + + @Override + public void saveProject(Project project) throws IOException, ClassCastException { + if (project instanceof GenericProject gp && gp.isDeleted()) { + // the project was explicitly deleted: never + // resurrect the marker or the attribute + return; + } + FileObject dir = project.getProjectDirectory(); + if (GenericProject.isAttributeProject(dir)) { + // local project: recognition lives in the FileObject + // attribute, never (re)create the marker file + dir.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); + } else { + GenericProject.createMarker(dir); + } + } +} diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpInfo.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpInfo.java new file mode 100644 index 000000000000..3ecc3667050c --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpInfo.java @@ -0,0 +1,83 @@ +/* + * 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.generic.project; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import javax.swing.Icon; +import javax.swing.UIManager; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; +import org.openide.util.ImageUtilities; + +/** + * The {@link ProjectInformation} of a generic project. The display + * name is the project folder name unless a display name override is + * stored in the project's private preferences (see + * {@link GenericProject#getDisplayNameOverride}). + */ +final class GpInfo implements ProjectInformation { + + private final GenericProject prj; + private final PropertyChangeSupport changeSupport = new PropertyChangeSupport(this); + + GpInfo(GenericProject prj) { + this.prj = prj; + } + + @Override + public String getName() { + return prj.getProjectDirectory().getNameExt(); + } + + @Override + public String getDisplayName() { + String override = GenericProject.getDisplayNameOverride(prj); + return override != null ? override : prj.getProjectDirectory().getNameExt(); + } + + @Override + public Icon getIcon() { + Icon icon = ImageUtilities.loadIcon(GenericProject.ICON); + return icon != null ? icon : UIManager.getIcon("Nb.Explorer.Folder.icon"); // NOI18N + } + + @Override + public Project getProject() { + return prj; + } + + /** + * Notifies listeners that the display name changed. + */ + void fireDisplayNameChanged() { + changeSupport.firePropertyChange(PROP_DISPLAY_NAME, null, null); + } + + @Override + public void addPropertyChangeListener(PropertyChangeListener listener) { + changeSupport.addPropertyChangeListener(listener); + } + + @Override + public void removePropertyChangeListener(PropertyChangeListener listener) { + changeSupport.removePropertyChangeListener(listener); + } +} diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpSubprojectProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpSubprojectProvider.java new file mode 100644 index 000000000000..02e228057a98 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpSubprojectProvider.java @@ -0,0 +1,71 @@ +/* + * 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.generic.project; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.swing.event.ChangeListener; +import org.netbeans.api.project.FileOwnerQuery; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.SubprojectProvider; +import org.openide.filesystems.FileObject; +import org.openide.util.ChangeSupport; + +/** + * The legacy {@link SubprojectProvider} view of the projects nested + * directly in the project directory: every child folder owned by + * another project is a subproject. + */ +final class GpSubprojectProvider implements SubprojectProvider { + + private final GenericProject prj; + private final ChangeSupport changeSupport = new ChangeSupport(this); + + GpSubprojectProvider(GenericProject prj) { + this.prj = prj; + } + + @Override + public Set getSubprojects() { + Set subprojects = new LinkedHashSet<>(); + FileObject[] children = prj.getProjectDirectory().getChildren(); + if (children != null) { + for (FileObject child : children) { + Project owner = FileOwnerQuery.getOwner(child); + if (owner != null && !owner.equals(prj)) { + subprojects.add(owner); + } + } + } + return Collections.unmodifiableSet(subprojects); + } + + @Override + public void addChangeListener(ChangeListener listener) { + changeSupport.addChangeListener(listener); + } + + @Override + public void removeChangeListener(ChangeListener listener) { + changeSupport.removeChangeListener(listener); + } + +} \ No newline at end of file diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index 3daf60fdbf1c..c7eb786c5a4c 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -55,19 +55,19 @@ public GenericProjectTest(String name) { public void testNoMarkerNotProject() throws Exception { FileObject root = newProjectDir(); - assertFalse(new GenericProject.GpFactory().isProject(root)); + assertFalse(new GpFactory().isProject(root)); } public void testMarkerFileIsProject() throws Exception { FileObject root = newProjectDir(); FileUtil.createData(root, GenericProject.MARKER_FILE); - assertTrue(new GenericProject.GpFactory().isProject(root)); + assertTrue(new GpFactory().isProject(root)); } public void testAttributeOnlyIsProject() throws Exception { FileObject root = newProjectDir(); root.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); - assertTrue(new GenericProject.GpFactory().isProject(root)); + assertTrue(new GpFactory().isProject(root)); } public void testLogicalViewShowsAllFiles() throws Exception { @@ -139,7 +139,7 @@ public void testSetMarkerStateLocalAndBack() throws Exception { GenericProject.setMarkerState(root, false); assertFalse(GenericProject.hasMarker(root)); assertEquals(Boolean.TRUE, root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); - assertTrue(new GenericProject.GpFactory().isProject(root)); + assertTrue(new GpFactory().isProject(root)); GenericProject.setMarkerState(root, true); assertTrue(GenericProject.hasMarker(root)); @@ -153,7 +153,7 @@ public void testSaveProjectLocalKeepsNoMarker() throws Exception { FileObject root = FileUtil.toFileObject(dir); Project prj = ProjectManager.getDefault().findProject(root); assertNotNull(prj); - new GenericProject.GpFactory().saveProject(prj); + new GpFactory().saveProject(prj); assertNull(root.getFileObject(GenericProject.MARKER_FILE)); assertEquals(Boolean.TRUE, root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); } @@ -285,7 +285,7 @@ public void testDeleteOperationPreventsMarkerResurrection() throws Exception { op.notifyDeleting(); root.getFileObject(GenericProject.MARKER_FILE).delete(); // simulate the stock flow - new GenericProject.GpFactory().saveProject(prj); + new GpFactory().saveProject(prj); assertNull("a saved deleted project must not be resurrected", root.getFileObject(GenericProject.MARKER_FILE)); } @@ -300,7 +300,7 @@ public void testDeleteOperationClearsAttribute() throws Exception { op.notifyDeleting(); assertNull("the recognition attribute must be dropped", root.getAttribute(GenericProject.MARKER_ATTRIBUTE)); - new GenericProject.GpFactory().saveProject(prj); + new GpFactory().saveProject(prj); assertNull("a saved deleted local project must not gain a marker file", root.getFileObject(GenericProject.MARKER_FILE)); } From 16c2ab0ff37bdf95461eac65fd9df489a64e5261 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Tue, 20 Oct 2026 23:18:05 +0200 Subject: [PATCH 11/15] fix FoD tests --- .../modules/ide/ergonomics/fod/FoDLayersProvider.java | 8 ++++++++ .../modules/ide/ergonomics/DynamicVerifyTest.java | 2 ++ 2 files changed, 10 insertions(+) diff --git a/ergonomics/ide.ergonomics/src/org/netbeans/modules/ide/ergonomics/fod/FoDLayersProvider.java b/ergonomics/ide.ergonomics/src/org/netbeans/modules/ide/ergonomics/fod/FoDLayersProvider.java index 9df5d459a331..52e11b94acaf 100755 --- a/ergonomics/ide.ergonomics/src/org/netbeans/modules/ide/ergonomics/fod/FoDLayersProvider.java +++ b/ergonomics/ide.ergonomics/src/org/netbeans/modules/ide/ergonomics/fod/FoDLayersProvider.java @@ -145,12 +145,20 @@ private boolean noAdditionalProjects() { if (pf.getClass().getName().contains("AntBasedProjectFactorySingleton")) { // NOI18N continue; } + // #162517 if (pf.getClass().getName().startsWith("org.netbeans.modules.ide.ergonomics")) { // NOI18N continue; } + // #248615 if (pf.getClass().getName().startsWith("org.netbeans.modules.project.ui.convertor.ProjectConvertorFactory")) { // NOI18N continue; } + // generic.project ships in the base ide cluster: its factory is + // always present and must not count as an "additional" project, + // or an empty IDE would never be detected as empty. + if (pf.getClass().getName().startsWith("org.netbeans.modules.generic.project")) { // NOI18N + continue; + } return false; } return ants.allItems().isEmpty(); diff --git a/ergonomics/ide.ergonomics/test/unit/src/org/netbeans/modules/ide/ergonomics/DynamicVerifyTest.java b/ergonomics/ide.ergonomics/test/unit/src/org/netbeans/modules/ide/ergonomics/DynamicVerifyTest.java index d17492fb3c93..893ec2a6d879 100644 --- a/ergonomics/ide.ergonomics/test/unit/src/org/netbeans/modules/ide/ergonomics/DynamicVerifyTest.java +++ b/ergonomics/ide.ergonomics/test/unit/src/org/netbeans/modules/ide/ergonomics/DynamicVerifyTest.java @@ -145,10 +145,12 @@ public void testGetAllProjectFactories() throws Exception { StringBuilder sb = new StringBuilder(); Map all = FeatureManager.projectFiles(); + // no project.file.* property in ergonomics/ide.ergonomics/ all.put("Fine", "org.netbeans.modules.project.ant.AntBasedProjectFactorySingleton"); all.put("FineToo", "org.netbeans.modules.project.ui.convertor.ProjectConvertorFactory"); all.put("MostlyOK", "org.netbeans.modules.java.openjdk.project.FilterStandardProjects"); all.put("NoProjectFileAvailable", "org.netbeans.modules.cpplite.project.CPPLiteProject$FactoryImpl"); + all.put("NoProjectFileAvailableGp", "org.netbeans.modules.generic.project.GpFactory"); /* * The "rust" cluster is not included in all cluster configurations in nbbuild/cluster.properties. From fc27d0cb1565e3afbe26b6ddd1ef0e78eeb5ae1b Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Sun, 25 Oct 2026 21:16:30 +0100 Subject: [PATCH 12/15] register new-project action shadow --- .../src/org/netbeans/modules/generic/project/layer.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml index 5bc99c7d4fea..066515e541f8 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/layer.xml @@ -35,6 +35,10 @@ + + + + From cfe7c535788818886463fa2d48efd5283b60393f Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 26 Oct 2026 10:10:06 +0100 Subject: [PATCH 13/15] Dual purpose auxiliary properties: marker and config - the same file is used for both: marker and config storage - initial marker is an empty properties file (which is valid) - mode switch from marker to private moves properties on access - file name is brandable note: NB's auxiliary config storage has a 5s IO flush delay --- .../generic/project/GenericProject.java | 15 +- .../project/GpAuxiliaryProperties.java | 242 ++++++++++++++++++ .../ui/actions/GpProjectOperations.java | 39 ++- .../generic/project/GenericProjectTest.java | 163 ++++++++++++ 4 files changed, 435 insertions(+), 24 deletions(-) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpAuxiliaryProperties.java diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 388e21afc682..520e82d78acd 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -40,10 +40,22 @@ * (private) projects, if it carries the {@link #MARKER_ATTRIBUTE} * FileObject attribute. The logical view of the project simply shows * all files contained in the project directory. + * + *

The marker file doubles as the project's auxiliary data file + * (see {@link GpAuxiliaryProperties}): it marks the directory as a + * project and stores what {@link ProjectUtils#getPreferences} + * persists (e.g. the display name override). For local (marker-less) + * projects the same data lives in the user configuration. */ public final class GenericProject implements Project { - public static final String MARKER_FILE = ".nbgp"; // NOI18N + /** + * Name of the marker file: identical to the auxiliary data file + * ({@link GpAuxiliaryProperties#dataFileName}), which thus has a + * dual role — it both marks the directory as a project and + * stores the project's auxiliary data. + */ + public static final String MARKER_FILE = GpAuxiliaryProperties.dataFileName(); /** * Name of the FileObject attribute that marks a project directory @@ -109,6 +121,7 @@ boolean isDeleted() { new GpCustomizerProvider(this), new GpProjectOperations(this), new GpActionProvider(this), + new GpAuxiliaryProperties(this), this ), "Projects/" + PROJECT_KEY + "/Lookup"); // NOI18N } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpAuxiliaryProperties.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpAuxiliaryProperties.java new file mode 100644 index 000000000000..d4e522088868 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpAuxiliaryProperties.java @@ -0,0 +1,242 @@ +/* + * 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.generic.project; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.netbeans.spi.project.AuxiliaryProperties; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.NbBundle.Messages; + +/** + * The {@link AuxiliaryProperties} of a generic project, stored in a + * single {@link Properties} file per project (name brandable via the + * module bundle, defaults to {@code .nbgp}). + * + *

The file's location depends on the project's nature: for a + * project with the marker file the marker + * ({@link GenericProject#MARKER_FILE}) is the data file, so + * the data is versioned along with the project; for a local + * (marker-less) project it lives in the user configuration + * (see {@link #dataFolder}), so that nothing auxiliary is written + * into the working tree. Shared and private settings are + * distinguished by a {@code shared} / {@code private} key prefix. + * + *

The location is re-evaluated on every access: if the marker + * file is created or deleted while the project is open, the data is + * moved to the new location on the next access (the file at the old + * one is removed); if it is deleted while no instance of the project + * is open, the data is lost with it. + */ +final class GpAuxiliaryProperties implements AuxiliaryProperties { + + private static final Logger LOG = Logger.getLogger(GpAuxiliaryProperties.class.getName()); + + private static final String PROJECTS_FOLDER = "Projects"; // NOI18N + private static final String SHARED_PREFIX = "shared."; // NOI18N + private static final String PRIVATE_PREFIX = "private."; // NOI18N + + private final GenericProject prj; + private final Properties data = new Properties(); + private boolean loaded; + private Boolean loadedInProject; + + GpAuxiliaryProperties(GenericProject prj) { + this.prj = prj; + } + + /** + * The path of the folder in the user configuration that holds the + * auxiliary data of a local project with the given directory. + */ + static String dataFolder(FileObject projectDirectory) { + return PROJECTS_FOLDER + "/" // NOI18N + + projectDirectory.getNameExt().replace('/', '_') + + "-" // NOI18N + + "%08x".formatted(projectDirectory.getPath().hashCode()); + } + + /** + * The name of the auxiliary data file, brandable via the module + * bundle, defaults to {@code .nbgp}: identical to the name of + * the marker file ({@link GenericProject#MARKER_FILE}). + */ + @Messages({ + "LBL_aux_data_file_name=.nbgp", + }) + static String dataFileName() { + return Bundle.LBL_aux_data_file_name(); + } + + /** + * Whether the auxiliary data is stored inside the project + * directory: this is the case when the marker file is present, + * i.e. the project is a "real", shareable project. + */ + private boolean inProject() { + return GenericProject.hasMarker(prj.getProjectDirectory()); + } + + @Override + public String get(String key, boolean shared) { + synchronized (data) { + ensureLoaded(); + return data.getProperty(keyPrefix(shared) + key); + } + } + + @Override + public void put(String key, String value, boolean shared) { + synchronized (data) { + ensureLoaded(); + String storedKey = keyPrefix(shared) + key; + if (value == null) { + data.remove(storedKey); + } else { + data.setProperty(storedKey, value); + } + save(); + } + } + + @Override + public Iterable listKeys(boolean shared) { + List keys = new ArrayList<>(); + synchronized (data) { + ensureLoaded(); + String prefix = keyPrefix(shared); + for (Object stored : data.keySet()) { + String key = (String) stored; + if (key.startsWith(prefix)) { + keys.add(key.substring(prefix.length())); + } + } + } + return keys; + } + + private static String keyPrefix(boolean shared) { + return shared ? SHARED_PREFIX : PRIVATE_PREFIX; + } + + /** + * Resolves the data file at the given location, returning + * {@code null} if it does not exist. + */ + private FileObject locateFile(boolean inProject) { + if (inProject) { + return prj.getProjectDirectory().getFileObject(dataFileName()); + } + FileObject root = FileUtil.getConfigRoot(); + FileObject dir = root != null + ? root.getFileObject(dataFolder(prj.getProjectDirectory())) + : null; + return dir != null ? dir.getFileObject(dataFileName()) : null; + } + + /** + * Loads the data if necessary. If the storage location has changed + * since the data was last loaded (the marker file was created or + * deleted), the cached data is moved to the new location and the + * file at the old location is removed. + */ + private void ensureLoaded() { + boolean here = inProject(); + if (loaded && loadedInProject == here) { + return; + } + if (!loaded) { + loaded = true; + loadedInProject = here; + FileObject file = locateFile(here); + if (file != null) { + readFile(file); + } + return; + } + // the location has changed since the last access: move the + // cached data to the new location and remove the file at the + // old one + boolean old = loadedInProject; + loadedInProject = here; + if (save()) { + FileObject oldFile = locateFile(old); + if (oldFile != null) { + try { + oldFile.delete(); + } catch (IOException ex) { + LOG.log(Level.FINE, "Cannot remove old auxiliary data file of " + prj, ex); + } + } + } + // the cache already holds what is now stored at the new + // location + } + + private void readFile(FileObject file) { + try (InputStream is = file.getInputStream()) { + data.load(is); + } catch (IOException ex) { + LOG.log(Level.WARNING, "Cannot read auxiliary data of " + prj, ex); + } + } + + /** + * Saves the cached data to the current location. + * + * @return whether the data has been written + */ + private boolean save() { + try { + FileObject dir; + if (inProject()) { + dir = prj.getProjectDirectory(); + } else { + FileObject root = FileUtil.getConfigRoot(); + if (root == null) { + LOG.log(Level.WARNING, "No user configuration available, cannot save auxiliary data of {0}", prj); + return false; + } + dir = FileUtil.createFolder(root, dataFolder(prj.getProjectDirectory())); + } + // createData throws if the file already exists, so only + // create it if necessary + FileObject file = dir.getFileObject(dataFileName()); + if (file == null) { + file = dir.createData(dataFileName()); + } + try (OutputStream os = file.getOutputStream()) { + data.store(os, "Auxiliary configuration of " + prj.getProjectDirectory().getPath()); + } + return true; + } catch (IOException ex) { + LOG.log(Level.WARNING, "Cannot save auxiliary data of " + prj, ex); + return false; + } + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java index 68b62260629c..29c8fa067457 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpProjectOperations.java @@ -32,27 +32,20 @@ import org.openide.filesystems.FileObject; /** - * Supports the default move, rename, copy and delete operations - * of generic projects. + * Supports the default move, rename, copy and delete operations of + * generic projects. * - *

The project is just a directory, so the marker file (when - * present) is the only metadata and everything contained in the - * directory is data. For move/rename nothing has to be updated: - * the directory — marker file and project attributes included — - * is relocated as a whole (the default move implementation does - * not use the data file list). For copy the stock flow duplicates - * the visible directory content as-is, so the new project needs - * no bookkeeping either. For delete the stock flow deletes the - * metadata, optionally the data, and finally the project folder - * if it is empty then, hence the data files are listed - * deepest-first. + *

The project is just a directory, everything in it is data (the + * marker file, when present, being the only metadata), so move and + * copy relocate or duplicate the directory as a whole; data files + * are listed deepest-first for the delete flow. Since the copy thus + * also inherits the project-local auxiliary data, {@link #notifyCopied} + * drops the display name override from the original, just like a + * move or folder rename, after which the folder name is + * authoritative again. * - *

A pure rename (the "Also rename project folder" option - * unchecked) does not touch the project directory; the new name - * is stored as a display name override, so the project's display - * name reports it while the programmatic name keeps the folder - * name. When the folder is renamed or moved, the (new) folder - * name is authoritative again and any override is dropped. + *

A pure rename (the folder is not renamed) does not touch the + * directory; the new name is kept as a display name override. */ public final class GpProjectOperations implements MoveOrRenameOperationImplementation, CopyOperationImplementation, DeleteOperationImplementation { @@ -133,10 +126,10 @@ public void notifyCopying() throws IOException { public void notifyCopied(Project original, File originalPath, String nueName) throws IOException { if (original != null) { // Called on the new project instance: keep the folder - // name authoritative, like after a move. In practice - // this is a no-op — the display name override lives in - // per-project preferences keyed by location, which the - // copy does not inherit. + // name authoritative, like after a move. The copy + // duplicates the project-local auxiliary data file (when + // the marker is present) as-is, so this drops the + // display name override inherited from the original. GenericProject.setDisplayNameOverride(prj, nueName); } } diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index c7eb786c5a4c..03379dfc37e5 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import java.util.prefs.Preferences; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.api.project.ProjectManager; @@ -32,6 +33,7 @@ import org.netbeans.modules.generic.project.ui.actions.GpProjectOperations; import org.netbeans.modules.generic.project.ui.wizard.GpWizardIterator; import org.netbeans.spi.project.ActionProvider; +import org.netbeans.spi.project.AuxiliaryProperties; import org.netbeans.spi.project.ProjectServiceProvider; import org.netbeans.spi.project.SubprojectProvider; import org.netbeans.spi.project.ui.LogicalViewProvider; @@ -358,6 +360,167 @@ public void testProjectLookupContainsLayerServices() throws Exception { prj.getLookup().lookup(ProjectInformation.class)); } + public void testProjectLookupProvidesAuxiliaryProperties() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + assertNotNull("the project must provide its own AuxiliaryProperties", + prj.getLookup().lookup(AuxiliaryProperties.class)); + } + + public void testMarkerProjectStoresAuxDataInProjectDir() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + + // the data must live in the project directory (the marker file + // is the data file)... + assertNotNull("the auxiliary data file must exist in the project directory", + root.getFileObject(GpAuxiliaryProperties.dataFileName())); + // ...and not in the user configuration + assertNull("no auxiliary data may be created in the user configuration for marker projects", + FileUtil.getConfigRoot().getFileObject( + GpAuxiliaryProperties.dataFolder(root) + "/" + GpAuxiliaryProperties.dataFileName())); + } + + public void testRepeatedWritesReuseTheDataFile() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + // several consecutive writes to the existing data file must + // not fail (createData throws if the file already exists) + GenericProject.setDisplayNameOverride((GenericProject) prj, "First Name"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + GenericProject.setDisplayNameOverride((GenericProject) prj, "Second Name"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + + assertNotNull("the auxiliary data file must exist", + root.getFileObject(GpAuxiliaryProperties.dataFileName())); + // a fresh instance (fresh cache) must see the second value, + // i.e. the second write has really reached the disk + GenericProject nue = new GenericProject(root); + assertEquals("Second Name", GenericProject.getDisplayNameOverride(nue)); + } + + public void testLocalProjectStoresAuxDataInUserConfig() throws Exception { + FileObject root = newProjectDir(); + root.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + + // the data must live in the user configuration... + assertNotNull("the auxiliary data file must be created in the user configuration", + FileUtil.getConfigRoot().getFileObject( + GpAuxiliaryProperties.dataFolder(root) + "/" + GpAuxiliaryProperties.dataFileName())); + // ...and nowhere in the project's working tree + assertNull("no auxiliary data file may appear in the project directory", + root.getFileObject(GpAuxiliaryProperties.dataFileName())); + assertNull("no auxiliary configuration attribute may be set on the project directory", + root.getAttribute("org.netbeans.spi.project.AuxiliaryConfiguration" + + ".http://www.netbeans.org/ns/auxiliary-configuration-preferences/1#preferences")); + } + + public void testMarkerToggledMovesAuxData() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + assertNotNull(root.getFileObject(GpAuxiliaryProperties.dataFileName())); + + // turning the marker off moves the data to the user configuration + GenericProject.setMarkerState(root, false); + assertEquals("My Fancy Project", GenericProject.getDisplayNameOverride((GenericProject) prj)); + assertNull("the project-local data file must be removed", + root.getFileObject(GpAuxiliaryProperties.dataFileName())); + assertNotNull("the data must now live in the user configuration", + FileUtil.getConfigRoot().getFileObject( + GpAuxiliaryProperties.dataFolder(root) + "/" + GpAuxiliaryProperties.dataFileName())); + + // and back on moves it back into the project directory + GenericProject.setMarkerState(root, true); + assertEquals("My Fancy Project", GenericProject.getDisplayNameOverride((GenericProject) prj)); + assertNotNull("the data must be back in the project directory", + root.getFileObject(GpAuxiliaryProperties.dataFileName())); + assertNull("no auxiliary data may remain in the user configuration", + FileUtil.getConfigRoot().getFileObject( + GpAuxiliaryProperties.dataFolder(root) + "/" + GpAuxiliaryProperties.dataFileName())); + } + + public void testMarkerDeletedExternallyDropsAuxData() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + + // the marker file is the auxiliary data file: deleting it + // outside of the IDE (while no instance is open to migrate + // the data) drops the data with it + root.getFileObject(GenericProject.MARKER_FILE).delete(); + root.setAttribute(GenericProject.MARKER_ATTRIBUTE, Boolean.TRUE); + + // a fresh instance must start with no auxiliary data and no + // file at either location + GenericProject nue = new GenericProject(root); + assertNull("the auxiliary data must be gone with the marker file", + GenericProject.getDisplayNameOverride(nue)); + assertNull(root.getFileObject(GpAuxiliaryProperties.dataFileName())); + assertNull("no auxiliary data may appear in the user configuration", + FileUtil.getConfigRoot().getFileObject( + GpAuxiliaryProperties.dataFolder(root) + "/" + GpAuxiliaryProperties.dataFileName())); + } + + public void testPreferencesSharedAndPrivateAreIsolated() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + Preferences shared = ProjectUtils.getPreferences(prj, GenericProject.class, true); + Preferences priv = ProjectUtils.getPreferences(prj, GenericProject.class, false); + shared.put("key", "shared-value"); // NOI18N + priv.put("key", "private-value"); // NOI18N + shared.flush(); + + assertEquals("shared-value", shared.get("key", null)); // NOI18N + assertEquals("private-value", priv.get("key", null)); // NOI18N + assertTrue("the shared key must be listed for the shared area", + List.of(shared.keys()).contains("key")); + assertTrue("the private key must be listed for the private area", + List.of(priv.keys()).contains("key")); + } + + public void testPreferencesSurviveNewProjectInstance() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + Project prj = ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setDisplayNameOverride((GenericProject) prj, "My Fancy Project"); + ProjectUtils.getPreferences(prj, GenericProject.class, false).flush(); + + // a fresh instance (bypassing the ProjectManager's project + // cache) must see the value, i.e. it has really been + // persisted to disk + GenericProject nue = new GenericProject(root); + assertEquals("My Fancy Project", GenericProject.getDisplayNameOverride(nue)); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N From 36a7171f80276b00718b80994cdd2a492a9c7309 Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 26 Oct 2026 14:30:23 +0100 Subject: [PATCH 14/15] File filter properties - global file filters are enabled by default - optional per-project filter added - both configurable via project properties --- ide/generic.project/nbproject/project.xml | 9 + .../generic/project/GenericProject.java | 90 ++++++++- .../modules/generic/project/GpFileFilter.java | 173 ++++++++++++++++++ .../project/GpLogicalViewProvider.java | 9 +- .../GpCompositeCategoryProvider.java | 70 +------ .../customizer/GpFilesCategoryProvider.java | 91 +++++++++ .../project/ui/customizer/GpFilesPanel.form | 94 ++++++++++ .../project/ui/customizer/GpFilesPanel.java | 110 +++++++++++ .../project/ui/customizer/GpGeneralPanel.form | 117 ++++++++++++ .../project/ui/customizer/GpGeneralPanel.java | 135 ++++++++++++++ .../generic/project/GenericProjectTest.java | 128 +++++++++++++ 11 files changed, 958 insertions(+), 68 deletions(-) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/GpFileFilter.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesCategoryProvider.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.form create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.java create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.form create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.java diff --git a/ide/generic.project/nbproject/project.xml b/ide/generic.project/nbproject/project.xml index d465bcf47ae2..3eabc1f4b778 100644 --- a/ide/generic.project/nbproject/project.xml +++ b/ide/generic.project/nbproject/project.xml @@ -60,6 +60,15 @@ 1.95.0.8 + + org.netbeans.modules.queries + + + + 1 + 1.27 + + org.openide.awt diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java index 520e82d78acd..67bc5f084cec 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GenericProject.java @@ -38,8 +38,11 @@ * A generic project. A directory is recognized as a generic project * if it contains the {@link #MARKER_FILE} or, for local * (private) projects, if it carries the {@link #MARKER_ATTRIBUTE} - * FileObject attribute. The logical view of the project simply shows - * all files contained in the project directory. + * FileObject attribute. The logical view of the project shows the + * files contained in the project directory, subject to the IDE's + * global "ignored files" filter (see + * {@link #isGlobalFileFilterEnabled}) and the project's own + * additional pattern (see {@link #getFilesFilterPattern}). * *

The marker file doubles as the project's auxiliary data file * (see {@link GpAuxiliaryProperties}): it marks the directory as a @@ -246,4 +249,87 @@ public static void setDisplayNameOverride(GenericProject prj, String name) { GpInfo info = (GpInfo) prj.getLookup().lookup(ProjectInformation.class); info.fireDisplayNameChanged(); } + + /** + * Key of the global file filter toggle in the project's private + * preferences ({@link ProjectUtils#getPreferences}). + */ + static final String GLOBAL_FILE_FILTER_KEY = "useGlobalFileFilter"; // NOI18N + + /** + * Whether the project's logical view applies the IDE's global + * "ignored files" filter (Tools/Options/File Types). Enabled by + * default. + */ + public static boolean isGlobalFileFilterEnabled(GenericProject prj) { + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, false); + if (prefs == null) { + return true; + } + return prefs.getBoolean(GLOBAL_FILE_FILTER_KEY, true); + } + + /** + * Stores the state of the global file filter toggle. The enabled + * state is the default, so it is stored by removing the key and a + * project with no other overrides keeps an empty data file. + */ + public static void setGlobalFileFilterEnabled(GenericProject prj, boolean enabled) { + if (isGlobalFileFilterEnabled(prj) == enabled) { + return; + } + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, false); + if (prefs == null) { + return; + } + if (enabled) { + prefs.remove(GLOBAL_FILE_FILTER_KEY); + } else { + prefs.put(GLOBAL_FILE_FILTER_KEY, "false"); // NOI18N + } + } + + /** + * Key of the additional files filter pattern in the project's + * shared preferences ({@link ProjectUtils#getPreferences}) + * — the pattern is project content, like a {@code .gitignore}. + */ + static final String FILES_FILTER_PATTERN_KEY = "fileFilterPattern"; // NOI18N + + /** + * Returns the project's additional "ignored files" pattern — a + * regular expression matched with {@code find()} on the file name + * only — or {@code null} if none is stored. + */ + public static String getFilesFilterPattern(GenericProject prj) { + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, true); + if (prefs == null) { + return null; + } + String pattern = prefs.get(FILES_FILTER_PATTERN_KEY, null); + return (pattern == null || pattern.isBlank()) ? null : pattern; + } + + /** + * Stores the project's additional "ignored files" pattern, or + * clears it for {@code null} or a blank string (the default, which + * keeps the data file clean). + */ + public static void setFilesFilterPattern(GenericProject prj, String pattern) { + if (pattern != null && pattern.isBlank()) { + pattern = null; + } + if (Objects.equals(pattern, getFilesFilterPattern(prj))) { + return; + } + Preferences prefs = ProjectUtils.getPreferences(prj, GenericProject.class, true); + if (prefs == null) { + return; + } + if (pattern == null) { + prefs.remove(FILES_FILTER_PATTERN_KEY); + } else { + prefs.put(FILES_FILTER_PATTERN_KEY, pattern); + } + } } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpFileFilter.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpFileFilter.java new file mode 100644 index 000000000000..5aaffbb50e4f --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpFileFilter.java @@ -0,0 +1,173 @@ +/* + * 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.generic.project; + +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.prefs.PreferenceChangeEvent; +import java.util.prefs.PreferenceChangeListener; +import java.util.prefs.Preferences; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import javax.swing.SwingUtilities; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.netbeans.api.project.ProjectUtils; +import org.netbeans.api.queries.VisibilityQuery; +import org.openide.filesystems.FileObject; +import org.openide.loaders.ChangeableDataFilter; +import org.openide.loaders.DataFilter; +import org.openide.loaders.DataObject; +import org.openide.util.ChangeSupport; +import org.openide.util.RequestProcessor; +import org.openide.util.WeakListeners; + +/** + * The data filter of the project's logical view: it hides the files + * matching the project's additional "ignored files" pattern (see + * {@link GenericProject#getFilesFilterPattern}) and, unless disabled + * per project (see {@link GenericProject#isGlobalFileFilterEnabled}), + * the files hidden by the IDE's global "ignored files" pattern. + * + *

Reacts to changes of the global pattern ({@link VisibilityQuery}) + * and of the project's settings (the private and shared preferences + * nodes) by notifying its listeners on the EDT, so the project node + * rescans the directory. The query listener is registered weakly; the + * {@link #privatePrefs} and {@link #sharedPrefs} fields pin the nodes, + * which the preferences provider references only weakly. + */ +final class GpFileFilter implements ChangeableDataFilter, DataFilter.FileBased, + ChangeListener, PreferenceChangeListener { + + private static final Logger LOG = Logger.getLogger(GpFileFilter.class.getName()); // NOI18N + + private final GenericProject project; + private final Preferences privatePrefs; + private final Preferences sharedPrefs; + + private volatile boolean globalFilterEnabled; + /** The additional pattern; {@code null} means no additional filter. */ + private volatile Pattern extraPattern; + /** The stored additional pattern text, for change detection. */ + private volatile String extraPatternText; + + private final ChangeSupport changeSupport = new ChangeSupport(this); + + GpFileFilter(GenericProject project) { + this.project = project; + this.privatePrefs = ProjectUtils.getPreferences(project, GenericProject.class, false); + this.sharedPrefs = ProjectUtils.getPreferences(project, GenericProject.class, true); + this.globalFilterEnabled = GenericProject.isGlobalFileFilterEnabled(project); + this.extraPatternText = GenericProject.getFilesFilterPattern(project); + this.extraPattern = compileFilesFilterPattern(GenericProject.getFilesFilterPattern(project)); + + VisibilityQuery.getDefault().addChangeListener(WeakListeners.change(this, VisibilityQuery.getDefault())); + // change events are delivered asynchronously, on the + // preferences event thread (not the EDT) + if (privatePrefs != null) { + privatePrefs.addPreferenceChangeListener(this); + } + if (sharedPrefs != null) { + sharedPrefs.addPreferenceChangeListener(this); + } + } + + @Override + public boolean acceptDataObject(DataObject obj) { + FileObject fo = obj.getPrimaryFile(); + return fo != null && acceptFileObject(fo); + } + + @Override + public boolean acceptFileObject(FileObject fo) { + if (extraPattern != null && extraPattern.matcher(fo.getNameExt()).find()) { + return false; + } + if (!globalFilterEnabled) { + return true; + } + return VisibilityQuery.getDefault().isVisible(fo); + } + + @Override + public void preferenceChange(PreferenceChangeEvent evt) { + String key = evt.getKey(); + boolean changed = false; + if (GenericProject.GLOBAL_FILE_FILTER_KEY.equals(key)) { + boolean nue = GenericProject.isGlobalFileFilterEnabled(project); + if (nue != globalFilterEnabled) { + globalFilterEnabled = nue; + changed = true; + } + } else if (GenericProject.FILES_FILTER_PATTERN_KEY.equals(key)) { + String nue = GenericProject.getFilesFilterPattern(project); + if (!Objects.equals(nue, extraPatternText)) { + extraPatternText = nue; + extraPattern = compileFilesFilterPattern(nue); + changed = true; + } + } + if (changed) { + stateChanged(new ChangeEvent(this)); + } + } + + @Override + public void stateChanged(ChangeEvent e) { + // forward on the EDT: the global pattern may change from any + // thread (e.g. the options dialog) and the preferences event + // arrives on the preferences event thread + if (SwingUtilities.isEventDispatchThread()) { + changeSupport.fireChange(); + } else { + RequestProcessor.getDefault().post(changeSupport::fireChange); + } + } + + @Override + public void addChangeListener(ChangeListener l) { + changeSupport.addChangeListener(l); + } + + @Override + public void removeChangeListener(ChangeListener l) { + changeSupport.removeChangeListener(l); + } + + /** + * Compiles the given additional files filter pattern, returning + * {@code null} for blank input or an invalid pattern (a warning is + * logged for the latter, which thus acts as no additional filter). + */ + private static Pattern compileFilesFilterPattern(String pattern) { + if (pattern == null || pattern.isBlank()) { + return null; + } + try { + return Pattern.compile(pattern); + } catch (PatternSyntaxException ex) { + LOG.log(Level.WARNING, "Invalid files filter pattern \"{0}\": {1}", + new Object[] { pattern, ex.getMessage() }); + return null; + } + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java index 8fdb5d4417da..2d3d3601197b 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/GpLogicalViewProvider.java @@ -28,7 +28,6 @@ import org.netbeans.spi.project.ui.LogicalViewProvider; import org.netbeans.spi.project.ui.support.CommonProjectActions; import org.openide.filesystems.FileObject; -import org.openide.loaders.DataFilter; import org.openide.loaders.DataObject; import org.openide.loaders.DataObjectNotFoundException; import org.openide.loaders.DataFolder; @@ -40,8 +39,10 @@ import org.openide.util.lookup.InstanceContent; /** - * The logical view of a generic project simply shows all files contained - * in the project directory. + * The logical view of a generic project shows the files contained + * in the project directory, filtered by the IDE's global "ignored + * files" pattern unless the project has disabled it in its + * customizer (see {@link GpFileFilter}). * *

The root node exposes the project, the project directory * {@link FileObject} and the directory's {@link DataObject} in its @@ -60,7 +61,7 @@ public Node createLogicalView() { FileObject dir = project.getProjectDirectory(); Children children; try { - children = DataFolder.findFolder(dir).createNodeChildren(DataFilter.ALL); + children = DataFolder.findFolder(dir).createNodeChildren(new GpFileFilter(project)); } catch (Exception ex) { children = Children.LEAF; } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java index 7f071a54440e..53226310361b 100644 --- a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpCompositeCategoryProvider.java @@ -22,14 +22,7 @@ import java.io.IOException; import java.util.logging.Level; import java.util.logging.Logger; -import javax.swing.GroupLayout; -import javax.swing.GroupLayout.Alignment; -import javax.swing.JCheckBox; import javax.swing.JComponent; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JTextField; -import javax.swing.LayoutStyle; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.modules.generic.project.GenericProject; @@ -40,7 +33,8 @@ /** * Provides the "General" category of the project customizer, showing - * the project name and location (both read-only). + * the project name and location and the marker file option, using + * the form-based {@link GpGeneralPanel}. * *

Additional categories can be added to the same dialog by registering * further {@link ProjectCustomizer.CompositeCategoryProvider} instances @@ -48,15 +42,6 @@ */ @Messages({ "LBL_General=General", - "LBL_Name=Name:", - "TT_Name=" - + "

The name of the project as shown in the project view.

" - + "

Changing it does not rename the project folder. Enter the folder name to reset.

", - "LBL_Location=Location:", - "LBL_MarkerFile=Marker file in project directory", - "TT_MarkerFile=" - + "

When unchecked, the .nbgp marker file is removed from the project directory.

" - + "

The project becomes a local (private) project.

", }) public final class GpCompositeCategoryProvider implements ProjectCustomizer.CompositeCategoryProvider { @@ -84,55 +69,16 @@ public JComponent createComponent(ProjectCustomizer.Category category, Lookup co ProjectInformation info = prj != null ? prj.getLookup().lookup(ProjectInformation.class) : null; FileObject projectDir = prj != null ? prj.getProjectDirectory() : null; - JLabel nameLabel = new JLabel(Bundle.LBL_Name()); - JLabel locationLabel = new JLabel(Bundle.LBL_Location()); - JTextField nameField = new JTextField(info != null ? info.getDisplayName() : ""); - nameField.setToolTipText(Bundle.TT_Name()); - JTextField locationField = new JTextField(projectDir != null ? projectDir.getPath() : ""); - locationField.setEditable(false); - JCheckBox markerBox = new JCheckBox(Bundle.LBL_MarkerFile()); - markerBox.setToolTipText(Bundle.TT_MarkerFile()); - markerBox.setSelected(projectDir != null && GenericProject.hasMarker(projectDir)); - - JPanel panel = new JPanel(); - GroupLayout layout = new GroupLayout(panel); - panel.setLayout(layout); - layout.setHorizontalGroup( - layout.createParallelGroup(Alignment.LEADING) - .addGroup(layout.createSequentialGroup() - .addContainerGap() - .addGroup(layout.createParallelGroup(Alignment.LEADING, false) - .addComponent(nameLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) - .addComponent(locationLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) - .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) - .addGroup(layout.createParallelGroup(Alignment.LEADING) - .addComponent(nameField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE) - .addComponent(locationField, GroupLayout.DEFAULT_SIZE, 200, Short.MAX_VALUE)) - .addContainerGap()) - .addGroup(layout.createSequentialGroup() - .addContainerGap() - .addComponent(markerBox)) - ); - layout.setVerticalGroup( - layout.createParallelGroup(Alignment.LEADING) - .addGroup(layout.createSequentialGroup() - .addGroup(layout.createParallelGroup(Alignment.BASELINE) - .addComponent(nameLabel) - .addComponent(nameField)) - .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) - .addGroup(layout.createParallelGroup(Alignment.BASELINE) - .addComponent(locationLabel) - .addComponent(locationField)) - .addGap(18, 18, 18) - .addComponent(markerBox) - .addGap(0, 0, Short.MAX_VALUE)) - ); + GpGeneralPanel panel = new GpGeneralPanel(); + panel.setNameText(info != null ? info.getDisplayName() : ""); + panel.setLocationText(projectDir != null ? projectDir.getPath() : ""); + panel.setMarkerEnabled(projectDir != null && GenericProject.hasMarker(projectDir)); if (prj != null && projectDir != null) { // invoked on OK, off the AWT thread, in ProjectManager write access category.setStoreListener(evt -> { try { - GenericProject.setMarkerState(projectDir, markerBox.isSelected()); - String name = nameField.getText().strip(); + GenericProject.setMarkerState(projectDir, panel.isMarkerEnabled()); + String name = panel.getNameText().strip(); if (!name.isEmpty()) { GenericProject.setDisplayNameOverride((GenericProject) prj, name); } diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesCategoryProvider.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesCategoryProvider.java new file mode 100644 index 000000000000..981fc64f7375 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesCategoryProvider.java @@ -0,0 +1,91 @@ +/* + * 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.generic.project.ui.customizer; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import javax.swing.JComponent; +import org.netbeans.api.project.Project; +import org.netbeans.modules.generic.project.GenericProject; +import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; + +/** + * Provides the "Files" category of the project customizer, toggling + * whether the project view applies the IDE's global "ignored files" + * filter and defining the project's own additional pattern. + */ +@Messages({ + "LBL_Files=Files", + "MSG_InvalidPattern=The additional ignored files pattern is not a valid regular expression:", +}) +public final class GpFilesCategoryProvider implements ProjectCustomizer.CompositeCategoryProvider { + + private static final String CATEGORY_FILES = "category-files"; // NOI18N + + @ProjectCustomizer.CompositeCategoryProvider.Registration( + projectType=GenericProject.PROJECT_KEY, + position=200 + ) + public static GpFilesCategoryProvider createFilesCategory() { + return new GpFilesCategoryProvider(); + } + + private GpFilesCategoryProvider() { + } + + @Override + public ProjectCustomizer.Category createCategory(Lookup context) { + return ProjectCustomizer.Category.create(CATEGORY_FILES, Bundle.LBL_Files(), null); + } + + @Override + public JComponent createComponent(ProjectCustomizer.Category category, Lookup context) { + Project prj = context.lookup(Project.class); + GenericProject gp = prj instanceof GenericProject g ? g : null; + GpFilesPanel panel = new GpFilesPanel(); + panel.setUseGlobalFilter(gp != null && GenericProject.isGlobalFileFilterEnabled(gp)); + String pattern = gp != null ? GenericProject.getFilesFilterPattern(gp) : null; + panel.setPatternText(pattern != null ? pattern : ""); + if (gp != null) { + // invoked on OK, off the AWT thread, in ProjectManager write access + category.setStoreListener(evt -> { + String text = panel.getPatternText().strip(); + if (!text.isEmpty()) { + try { + Pattern.compile(text); + } catch (PatternSyntaxException ex) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message( + Bundle.MSG_InvalidPattern() + "\n" + ex.getMessage(), + NotifyDescriptor.ERROR_MESSAGE)); + return; + } + } + GenericProject.setGlobalFileFilterEnabled(gp, panel.isUseGlobalFilter()); + GenericProject.setFilesFilterPattern(gp, text.isEmpty() ? null : text); + }); + } + return panel; + } + +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.form b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.form new file mode 100644 index 000000000000..09aaace65fdf --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.form @@ -0,0 +1,94 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.java new file mode 100644 index 000000000000..8ada941c653d --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpFilesPanel.java @@ -0,0 +1,110 @@ +/* + * 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.generic.project.ui.customizer; + +import javax.swing.GroupLayout; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.LayoutStyle; +import org.openide.awt.Mnemonics; +import org.openide.util.NbBundle; + +/** + * Form panel of the "Files" project customizer category. + */ +public class GpFilesPanel extends JPanel { + + public GpFilesPanel() { + initComponents(); + } + + @NbBundle.Messages({ + "LBL_UseGlobalFilter=Apply the IDE's ignored files pattern", + "TT_UseGlobalFilter=" + + "

When checked, files matching the IDE's global ignored files pattern " + + "are hidden in the project view.


" + + "

See Tools -> Options -> Miscellaneous -> Files

", + "LBL_Pattern=Additional ignored files pattern:", + "TT_Pattern=" + + "

A regular expression matched against the file name.

" + + "

Files matching it are hidden in the project view,

" + }) + // //GEN-BEGIN:initComponents + private void initComponents() { + + useGlobalFilterCheckBox = new JCheckBox(); + patternLabel = new JLabel(); + patternField = new JTextField(); + + Mnemonics.setLocalizedText(useGlobalFilterCheckBox, NbBundle.getMessage(GpFilesPanel.class, "LBL_UseGlobalFilter")); // NOI18N + useGlobalFilterCheckBox.setToolTipText(NbBundle.getMessage(GpFilesPanel.class, "TT_UseGlobalFilter")); // NOI18N + + Mnemonics.setLocalizedText(patternLabel, NbBundle.getMessage(GpFilesPanel.class, "LBL_Pattern")); // NOI18N + + patternField.setToolTipText(NbBundle.getMessage(GpFilesPanel.class, "TT_Pattern")); // NOI18N + + GroupLayout layout = new GroupLayout(this); + this.setLayout(layout); + layout.setHorizontalGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addComponent(patternLabel) + .addPreferredGap(LayoutStyle.ComponentPlacement.UNRELATED) + .addComponent(patternField, GroupLayout.DEFAULT_SIZE, 186, Short.MAX_VALUE) + .addContainerGap()) + .addGroup(layout.createSequentialGroup() + .addComponent(useGlobalFilterCheckBox) + .addGap(0, 0, Short.MAX_VALUE)) + ); + layout.setVerticalGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE) + .addComponent(patternLabel) + .addComponent(patternField, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)) + .addGap(18, 18, 18) + .addComponent(useGlobalFilterCheckBox) + .addContainerGap(GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + ); + }// //GEN-END:initComponents + + public boolean isUseGlobalFilter() { + return useGlobalFilterCheckBox.isSelected(); + } + + public void setUseGlobalFilter(boolean value) { + useGlobalFilterCheckBox.setSelected(value); + } + + public String getPatternText() { + return patternField.getText(); + } + + public void setPatternText(String value) { + patternField.setText(value); + } + + // Variables declaration - do not modify//GEN-BEGIN:variables + JTextField patternField; + JLabel patternLabel; + JCheckBox useGlobalFilterCheckBox; + // End of variables declaration//GEN-END:variables +} \ No newline at end of file diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.form b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.form new file mode 100644 index 000000000000..a05d4d1ea643 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.form @@ -0,0 +1,117 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.java new file mode 100644 index 000000000000..c5ee2002fcd6 --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/customizer/GpGeneralPanel.java @@ -0,0 +1,135 @@ +/* + * 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.generic.project.ui.customizer; + +import javax.swing.GroupLayout; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.LayoutStyle; +import org.openide.awt.Mnemonics; +import org.openide.util.NbBundle; + +/** + * Form panel of the "General" project customizer category. + */ +public class GpGeneralPanel extends JPanel { + + public GpGeneralPanel() { + initComponents(); + } + + @NbBundle.Messages({ + "LBL_Name=Name:", + "TT_Name=" + + "

The name of the project as shown in the project view.

" + + "

Changing it does not rename the project folder. Enter the folder name to reset.

", + "LBL_Location=Location:", + "LBL_MarkerFile=Marker file in project directory", + "TT_MarkerFile=" + + "

When unchecked, the .nbgp marker file is removed from the project directory.

" + + "

The project becomes a local (private) project.

", + }) + @SuppressWarnings("unchecked") + // //GEN-BEGIN:initComponents + private void initComponents() { + + nameLabel = new JLabel(); + nameField = new JTextField(); + locationLabel = new JLabel(); + locationField = new JTextField(); + markerBox = new JCheckBox(); + + Mnemonics.setLocalizedText(nameLabel, NbBundle.getMessage(GpGeneralPanel.class, "LBL_Name")); // NOI18N + + nameField.setToolTipText(NbBundle.getMessage(GpGeneralPanel.class, "TT_Name")); // NOI18N + + Mnemonics.setLocalizedText(locationLabel, NbBundle.getMessage(GpGeneralPanel.class, "LBL_Location")); // NOI18N + + locationField.setEditable(false); + + Mnemonics.setLocalizedText(markerBox, NbBundle.getMessage(GpGeneralPanel.class, "LBL_MarkerFile")); // NOI18N + markerBox.setToolTipText(NbBundle.getMessage(GpGeneralPanel.class, "TT_MarkerFile")); // NOI18N + + GroupLayout layout = new GroupLayout(this); + this.setLayout(layout); + layout.setHorizontalGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING, false) + .addComponent(nameLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) + .addComponent(locationLabel, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + .addPreferredGap(LayoutStyle.ComponentPlacement.UNRELATED) + .addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING) + .addComponent(locationField, GroupLayout.Alignment.LEADING, GroupLayout.DEFAULT_SIZE, 204, Short.MAX_VALUE) + .addComponent(nameField)) + .addContainerGap()) + .addGroup(layout.createSequentialGroup() + .addComponent(markerBox) + .addGap(0, 0, Short.MAX_VALUE)) + ); + layout.setVerticalGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE) + .addComponent(nameField, GroupLayout.PREFERRED_SIZE, 22, GroupLayout.PREFERRED_SIZE) + .addComponent(nameLabel)) + .addPreferredGap(LayoutStyle.ComponentPlacement.UNRELATED) + .addGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE) + .addComponent(locationLabel) + .addComponent(locationField, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)) + .addGap(18, 18, 18) + .addComponent(markerBox) + .addContainerGap(GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)) + ); + }// //GEN-END:initComponents + + public String getNameText() { + return nameField.getText(); + } + + public void setNameText(String value) { + nameField.setText(value); + } + + public String getLocationText() { + return locationField.getText(); + } + + public void setLocationText(String value) { + locationField.setText(value); + } + + public boolean isMarkerEnabled() { + return markerBox.isSelected(); + } + + public void setMarkerEnabled(boolean value) { + markerBox.setSelected(value); + } + + // Variables declaration - do not modify//GEN-BEGIN:variables + JTextField locationField; + JLabel locationLabel; + JCheckBox markerBox; + JTextField nameField; + JLabel nameLabel; + // End of variables declaration//GEN-END:variables +} \ No newline at end of file diff --git a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java index 03379dfc37e5..1b0ba3c76d93 100644 --- a/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java +++ b/ide/generic.project/test/unit/src/org/netbeans/modules/generic/project/GenericProjectTest.java @@ -23,12 +23,15 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.prefs.Preferences; import org.netbeans.api.project.Project; import org.netbeans.api.project.ProjectInformation; import org.netbeans.api.project.ProjectManager; import org.netbeans.api.project.ProjectUtils; +import org.netbeans.api.queries.VisibilityQuery; import org.netbeans.junit.NbTestCase; import org.netbeans.modules.generic.project.ui.actions.GpProjectOperations; import org.netbeans.modules.generic.project.ui.wizard.GpWizardIterator; @@ -521,6 +524,131 @@ public void testPreferencesSurviveNewProjectInstance() throws Exception { assertEquals("My Fancy Project", GenericProject.getDisplayNameOverride(nue)); } + public void testGlobalFileFilterEnabledByDefault() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + assertTrue("the global file filter must be enabled by default", + GenericProject.isGlobalFileFilterEnabled(prj)); + } + + public void testGlobalFileFilterToggleRoundTrip() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GenericProject.setGlobalFileFilterEnabled(prj, false); + assertFalse(GenericProject.isGlobalFileFilterEnabled(prj)); + // the disabled state is the only one that must be stored + assertEquals("false", ProjectUtils.getPreferences(prj, GenericProject.class, false) + .get(GenericProject.GLOBAL_FILE_FILTER_KEY, null)); // NOI18N + + GenericProject.setGlobalFileFilterEnabled(prj, true); + assertTrue(GenericProject.isGlobalFileFilterEnabled(prj)); + // the enabled state is the default and keeps the data file clean + assertNull(ProjectUtils.getPreferences(prj, GenericProject.class, false) + .get(GenericProject.GLOBAL_FILE_FILTER_KEY, null)); // NOI18N + } + + public void testFilterHidesFilesTheIdeIgnores() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileUtil.createData(root, "readme.txt"); + FileObject git = FileUtil.createData(root, ".git"); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + // sanity: in the test environment the IDE's global filter + // (masterfs' implementation) hides ".git" + assertFalse(VisibilityQuery.getDefault().isVisible(git)); + + GpFileFilter filter = new GpFileFilter(prj); + assertFalse("the filter must hide files the IDE ignores", + filter.acceptFileObject(git)); + assertTrue("regular files must stay visible", + filter.acceptFileObject(root.getFileObject("readme.txt"))); + + CountDownLatch latch = new CountDownLatch(1); + filter.addChangeListener(evt -> latch.countDown()); + GenericProject.setGlobalFileFilterEnabled(prj, false); + assertTrue("the filter must be notified when the toggle changes", + latch.await(10, TimeUnit.SECONDS)); + assertTrue("disabling the filter must show everything", + filter.acceptFileObject(git)); + } + + public void testFilesFilterPatternRoundTrip() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + assertNull("no additional pattern by default", + GenericProject.getFilesFilterPattern(prj)); + + GenericProject.setFilesFilterPattern(prj, ".*-old.*"); // NOI18N + assertEquals(".*-old.*", GenericProject.getFilesFilterPattern(prj)); + // the pattern is project content and lives in the shared area + assertEquals(".*-old.*", ProjectUtils.getPreferences(prj, GenericProject.class, true) + .get(GenericProject.FILES_FILTER_PATTERN_KEY, null)); // NOI18N + + // a blank pattern is the same as none + GenericProject.setFilesFilterPattern(prj, " "); // NOI18N + assertNull(GenericProject.getFilesFilterPattern(prj)); + assertNull(ProjectUtils.getPreferences(prj, GenericProject.class, true) + .get(GenericProject.FILES_FILTER_PATTERN_KEY, null)); // NOI18N + } + + public void testAdditionalFilterPattern() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileUtil.createData(root, "readme.txt"); + FileUtil.createData(root, "readme-old.txt"); + FileObject git = FileUtil.createData(root, ".git"); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + GpFileFilter filter = new GpFileFilter(prj); + assertTrue(filter.acceptFileObject(root.getFileObject("readme.txt"))); + + // an additional pattern hides its matches on top of the global + // filter (the two are AND-combined) + CountDownLatch latch = new CountDownLatch(1); + filter.addChangeListener(evt -> latch.countDown()); + GenericProject.setFilesFilterPattern(prj, ".*-old.*"); // NOI18N + assertTrue("the filter must be notified when the pattern changes", + latch.await(10, TimeUnit.SECONDS)); + assertFalse("files matching the additional pattern must be hidden", + filter.acceptFileObject(root.getFileObject("readme-old.txt"))); + assertTrue("other files must stay visible", + filter.acceptFileObject(root.getFileObject("readme.txt"))); + assertFalse("the global filter must still apply", + filter.acceptFileObject(git)); + + // clearing the pattern shows the files again + final CountDownLatch latch2 = new CountDownLatch(1); + filter.addChangeListener(evt -> latch2.countDown()); + GenericProject.setFilesFilterPattern(prj, null); + assertTrue(latch2.await(10, TimeUnit.SECONDS)); + assertTrue(filter.acceptFileObject(root.getFileObject("readme-old.txt"))); + } + + public void testInvalidAdditionalPatternIsIgnored() throws Exception { + FileObject root = newProjectDir(); + FileUtil.createData(root, GenericProject.MARKER_FILE); + FileUtil.createData(root, "readme-old.txt"); + GenericProject prj = (GenericProject) ProjectManager.getDefault().findProject(root); + assertNotNull(prj); + + // simulate a hand-edited data file with a broken pattern: + // it must act as no additional filter (a warning is logged) + GenericProject.setFilesFilterPattern(prj, ".*-old("); // NOI18N + GpFileFilter filter = new GpFileFilter(prj); + assertTrue("an invalid pattern must not hide files", + filter.acceptFileObject(root.getFileObject("readme-old.txt"))); + } + private FileObject newProjectDir() throws Exception { clearWorkDir(); File dir = new File(getWorkDir(), "proj-" + getName()); // NOI18N From a33d972e7133ba2b8738b12d0f393b68be64742b Mon Sep 17 00:00:00 2001 From: Michael Bien Date: Mon, 26 Oct 2026 22:27:43 +0100 Subject: [PATCH 15/15] Open as Generic Project action inverse of the open project action, this action can convert any non-project to a generic project --- ide/generic.project/nbproject/project.xml | 9 ++ .../ui/actions/GpOpenAsProjectAction.java | 152 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpOpenAsProjectAction.java diff --git a/ide/generic.project/nbproject/project.xml b/ide/generic.project/nbproject/project.xml index 3eabc1f4b778..4e45be8f99bc 100644 --- a/ide/generic.project/nbproject/project.xml +++ b/ide/generic.project/nbproject/project.xml @@ -60,6 +60,15 @@ 1.95.0.8
+ + org.netbeans.modules.projectuiapi.base + + + + 1 + 1.101 + + org.netbeans.modules.queries diff --git a/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpOpenAsProjectAction.java b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpOpenAsProjectAction.java new file mode 100644 index 000000000000..d058200d1b8a --- /dev/null +++ b/ide/generic.project/src/org/netbeans/modules/generic/project/ui/actions/GpOpenAsProjectAction.java @@ -0,0 +1,152 @@ +/* + * 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.generic.project.ui.actions; + +import java.awt.event.ActionEvent; +import java.io.File; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.AbstractAction; +import javax.swing.Action; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectManager; +import org.netbeans.api.project.ui.OpenProjects; +import org.netbeans.modules.generic.project.ui.wizard.GpWizardIterator; +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.awt.DynamicMenuContent; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.loaders.DataFolder; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; +import org.openide.util.RequestProcessor; +import static org.netbeans.modules.generic.project.ui.actions.Bundle.*; + +/** + * Context action for folder nodes: turns a folder which is not a project + * yet into a local generic project — recognition by the FileObject + * attribute only, no marker file in the working tree — and opens it in the + * Projects tab. + *

+ * Registered on {@code Loaders/folder/any/Actions}, hence available on any + * folder node (e.g. in the Favorites tab or beneath an open project). + * Mirrors the inverse action {@code OpenProjectFolderAction} of the + * projectui module. + * + * @author mbien + */ +@Messages("GpOpenAsProjectAction.LBL_action=Open as Generic Project") +@ActionID(category = "Project", id = "org.netbeans.modules.generic.project.ui.actions.GpOpenAsProjectAction") +@ActionRegistration(displayName = "#GpOpenAsProjectAction.LBL_action", lazy = false) +@ActionReference(path = "Loaders/folder/any/Actions", position = 110) +public final class GpOpenAsProjectAction extends AbstractAction implements ContextAwareAction { + + private static final Logger LOG = Logger.getLogger(GpOpenAsProjectAction.class.getName()); + private static final RequestProcessor RP = new RequestProcessor(GpOpenAsProjectAction.class); + + public GpOpenAsProjectAction() { + super(GpOpenAsProjectAction_LBL_action()); + } + + @Override + public void actionPerformed(ActionEvent e) { + // Cannot be invoked without any context. + assert false; + } + + @Override + public Action createContextAwareInstance(Lookup context) { + return new ContextAction(context); + } + + private static final class ContextAction extends AbstractAction { + + private final Lookup context; + + public ContextAction(Lookup context) { + super(GpOpenAsProjectAction_LBL_action()); + this.context = context; + checkEnabled(context); + } + + private void checkEnabled(Lookup context1) { + for (DataFolder d : context1.lookupAll(DataFolder.class)) { + if (isCandidate(d.getPrimaryFile())) { + return; + } + } + putValue(DynamicMenuContent.HIDE_WHEN_DISABLED, true); + setEnabled(false); + } + + @Override + public void actionPerformed(ActionEvent e) { + // Run asynch so that the UI is not blocked; the open + // shows a progress dialog. + RP.post(() -> { + for (DataFolder d : context.lookupAll(DataFolder.class)) { + FileObject fo = d.getPrimaryFile(); + if (isCandidate(fo)) { + try { + adopt(fo); + return; + } catch (IOException x) { + LOG.log(Level.WARNING, "Cannot open folder as a generic project: " + fo, x); // NOI18N + } + } + } + }); + } + + /** A folder on the local file system which is not a project yet. */ + private static boolean isCandidate(FileObject fo) { + File f = FileUtil.toFile(fo); + return f != null && f.isDirectory() && !ProjectManager.getDefault().isProject(fo); + } + + /** + * Marks the folder as a local generic project (attribute only, no + * marker file) and opens the resulting project. + */ + private static void adopt(FileObject fo) throws IOException { + File dir = FileUtil.normalizeFile(FileUtil.toFile(fo)); + GpWizardIterator.createProject(dir, true); + // The folder may be cached as a non-project from the + // isProject() check done to enable this action. + ProjectManager.getDefault().clearNonProjectCache(); + Project p = ProjectManager.getDefault().findProject(fo); + if (p == null) { + throw new IOException("Project not found in " + dir); // NOI18N + } + OpenProjects.getDefault().open(new Project[]{p}, false, true); + File parent = dir.getParentFile(); + if (parent != null && parent.exists()) { + ProjectChooser.setProjectsFolder(parent); + } + } + + } + +} \ No newline at end of file