Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ide/projectapi.nb/nbproject/project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
# specific language governing permissions and limitations
# under the License.
is.eager=true
javac.release=17
javac.release=21
javac.compilerargs=-Xlint -Xlint:-serial
2 changes: 1 addition & 1 deletion ide/projectapi.nb/nbproject/project.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
<compile-dependency/>
<run-dependency>
<release-version>1</release-version>
<specification-version>1.78</specification-version>
<specification-version>1.111</specification-version>
</run-dependency>
</dependency>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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.projectapi.nb;

import java.beans.PropertyChangeListener;
import javax.swing.Icon;
import javax.swing.event.ChangeListener;
import org.netbeans.api.project.Project;
import org.netbeans.api.project.ProjectInformation;
import org.netbeans.api.project.SourceGroup;
import org.netbeans.api.project.Sources;
import org.netbeans.spi.project.ProjectState;
import org.netbeans.spi.project.support.GenericSources;
import org.netbeans.spi.project.support.LookupProviderSupport;
import org.openide.filesystems.FileObject;
import org.openide.util.ImageUtilities;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.lookup.Lookups;

final class FallbackProject implements Project, ProjectInformation, Sources {
private static final String DASHNAME = "org-netbeans-modules-project-fallback"; // NOI18N
private static final String ICON = "org/netbeans/modules/projectapi/nb/fallback.svg"; // NOI18N

private final FileObject dir;
private final ProjectState state;
private final Lookup lkp;
private final SourceGroup genericGroup;

public FallbackProject(FileObject dir, ProjectState state) {
this.dir = dir;
this.state = state;
this.genericGroup = GenericSources.group(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nested fallback’s source group can exclude its own root
Query ownership of a child folder inside an existing project, then create a fallback for that child. Ownership remains cached against the parent, so GenericSources.group(...).contains(root) returns false, violating the SourceGroup contract. Fallback registration must reconcile cached ownership with its source-group implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one was tricky! A test based on your example and a fix is in as e348182. Thank you, Martine!

this, dir,
Sources.TYPE_GENERIC, getDisplayName(),
getIcon(), getIcon()
);
Lookup basicLookup = Lookups.fixed(
this,
dir,
LookupProviderSupport.createActionProviderMerger()
);
this.lkp = LookupProviderSupport.createCompositeLookup(basicLookup, "Projects/" + DASHNAME + "/Lookup");
}

@Override
public FileObject getProjectDirectory() {
return dir;
}

@Override
public Lookup getLookup() {
return lkp;
}

@Override
public String getName() {
return dir.getNameExt();
}

@Override
@NbBundle.Messages({
"# {0} - name of the folder",
"CTL_FallbackProjectDisplayName=Folder {0}"
})
public String getDisplayName() {
return Bundle.CTL_FallbackProjectDisplayName(getName());
}

@Override
public Icon getIcon() {
Icon icon = ImageUtilities.loadIcon(ICON);
assert icon != null;
return icon;
}

@Override
public Project getProject() {
return this;
}

@Override
public void addPropertyChangeListener(PropertyChangeListener listener) {
}

@Override
public void removePropertyChangeListener(PropertyChangeListener listener) {
}

@Override
public SourceGroup[] getSourceGroups(String type) {
if (Sources.TYPE_GENERIC.equals(type)) {
return new SourceGroup[] { genericGroup };
} else {
return new SourceGroup[0];
}
}

@Override
public void addChangeListener(ChangeListener listener) {
}

@Override
public void removeChangeListener(ChangeListener listener) {
}

void notifyDeleted() {
state.notifyDeleted();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public void resultChanged(LookupEvent e) {
}
});
}

private static enum LoadStatus {
/**
* Marker for a directory which is known to not be a project.
Expand Down Expand Up @@ -212,6 +212,17 @@ public Mutex getMutex(
*/
@Override
public Project findProject(final FileObject projectDirectory) throws IOException, IllegalArgumentException {
return findProjectImpl(projectDirectory, false);
Comment thread
jtulach marked this conversation as resolved.
}

@Override
public Project findProject(FileObject projectDirectory, FindOptions options) throws IOException, IllegalArgumentException {
var found = findProjectImpl(projectDirectory, options.isFallbackAllowed());
assert !options.isFallbackAllowed() || found != null;
return found;
}

private Project findProjectImpl(FileObject projectDirectory, boolean fallback) throws IOException, IllegalArgumentException {
Parameters.notNull("projectDirectory", projectDirectory); //NOI18N
try {
return getMutex().readAccess(new Mutex.ExceptionAction<Project>() {
Expand Down Expand Up @@ -250,11 +261,17 @@ public Project run() throws IOException {
assert !LoadStatus.LOADING_PROJECT.is(o);
wasSomeSuchProject = LoadStatus.SOME_SUCH_PROJECT.is(o);
if (LoadStatus.NO_SUCH_PROJECT.is(o)) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "findProject({0}) in {1}: NO_SUCH_PROJECT", new Object[] {projectDirectory, Thread.currentThread().getName()});
if (fallback) {
// treat a not checked project yet
o = null;
} else {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "findProject({0}) in {1}: NO_SUCH_PROJECT", new Object[]{projectDirectory, Thread.currentThread().getName()});
}
return null;
}
return null;
} else if (o != null && !LoadStatus.SOME_SUCH_PROJECT.is(o)) {
}
if (o != null && !LoadStatus.SOME_SUCH_PROJECT.is(o)) {

@MartinBalin MartinBalin Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallbacks break existing project recognition consumers
Once cached, a fallback is returned by ordinary findProject(). Consequently, FileOwnerQuery assigns previously unowned Java files to it, disabling single-file Run/Debug and classpath support, which require a null owner. It also makes ProjectGenerator.createProject() reject the folder as already containing a project. Preserve these existing consumers’ behavior before exposing fallbacks through the ordinary cache.

Cache clearing returns before replacing fallbacks under write access — NbProjectManager.java (line 517)
postReadRequest() defers execution until the current write lock is released. Creating project metadata, calling clearNonProjectCache(), then calling findProject() within that lock therefore still returns the fallback. Replacement needs to take effect before the subsequent lookup.

Replaced fallbacks remain valid and can retain file ownership — NbProjectManager.java (line 525)
Removal updates only dir2Proj. The fallback remains in proj2Factory, so isValid(fallback) stays true. When replacement is triggered by a factory lookup change, the file-owner cache also remains stale: findProject() returns the real project while FileOwnerQuery returns the fallback. Retire the old instance and invalidate ownership caches together.

@jtulach jtulach Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Martine. Some of these are similar to Toni's findings. Some are new. All fixed in e348182

Project p = o.first().get();
if (p != null) {
if (LOG.isLoggable(Level.FINE)) {
Expand Down Expand Up @@ -285,7 +302,7 @@ public Project run() throws IOException {
}
boolean resetLP = false;
try {
Project p = createProject(projectDirectory);
Project p = createProject(projectDirectory, fallback);
//Thread.dumpStack();
synchronized (dir2Proj) {
dir2Proj.notifyAll();
Expand Down Expand Up @@ -366,7 +383,7 @@ public Project run() throws IOException {
* @return a project made from it, or null if it is not recognized
* @throws IOException if there was a problem loading the project
*/
private Project createProject(FileObject dir) throws IOException {
private Project createProject(FileObject dir, boolean fallback) throws IOException {
assert dir != null;
assert dir.isFolder();
assert getMutex().isReadAccess();
Expand All @@ -384,7 +401,16 @@ private Project createProject(FileObject dir) throws IOException {
return p;
}
}
return null;
if (fallback) {
FallbackProject p = new FallbackProject(dir, state);
proj2Factory.put(p, null);
state.attach(p);
// need to clear ownership caches
callBack.notifyDeleted(p);
return p;
} else {
return null;
}
}


Expand Down Expand Up @@ -415,7 +441,12 @@ public Result run() {
if (LoadStatus.NO_SUCH_PROJECT.is(o)) {
return null;
} else if (o != null) {
// Reference<Project> or SOME_SUCH_PROJECT
if (o.hasFirst() && o.first().get() instanceof Project existingProject) {
// Reference<Project>
ProjectInformation info = ProjectUtils.getInformation(existingProject);
return new Result(info.getIcon());
}
// SOME_SUCH_PROJECT
// rather check for result than load project and lookup projectInformation for icon.
return checkForProject(projectDirectory);
}
Expand Down Expand Up @@ -459,7 +490,7 @@ public Result run() {
private Result checkForProject(FileObject dir) {
assert dir != null;
assert dir.isFolder() : dir;
assert getMutex().isReadAccess();
assert getMutex().isReadAccess() || getMutex().isWriteAccess();
Iterator<? extends ProjectFactory> it = factories.allInstances().iterator();
while (it.hasNext()) {
ProjectFactory factory = it.next();
Expand Down Expand Up @@ -489,11 +520,30 @@ public void clearNonProjectCache() {
LoadStatus.NO_SUCH_PROJECT.wrap(),
LoadStatus.SOME_SUCH_PROJECT.wrap(),
}));
// XXX remove everything too? but then e.g. AntProjectFactorySingleton
// will stay while its delegates are changed, which does no good
// XXX should there be any way to signal that a particular
// folder should be "reloaded" by a new factory?
}
MUTEX.postWriteRequest(() -> {
synchronized (dir2Proj) {
// check if FallbackProject is still needed

Iterator<Map.Entry<FileObject, Union2<Reference<Project>, LoadStatus>>> it = dir2Proj.entrySet().iterator();
Comment thread
jtulach marked this conversation as resolved.
while (it.hasNext()) {
Map.Entry<FileObject, Union2<Reference<Project>, LoadStatus>> entry = it.next();
if (entry.getValue().hasFirst() && entry.getValue().first().get() instanceof FallbackProject fallback) {
Result result = checkForProject(fallback.getProjectDirectory());
if (result != null) {
it.remove();
proj2Factory.remove(fallback);
fallback.notifyDeleted();
}
}
}
}
});

// XXX remove everything too? but then e.g. AntProjectFactorySingleton
// will stay while its delegates are changed, which does no good
// XXX should there be any way to signal that a particular
// folder should be "reloaded" by a new factory?
}

private final class ProjectStateImpl implements ProjectState {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Comment thread
jtulach marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading