Repository navigation
Directory groups → Generic Project ("dirproject") #9738
Description
this was generated during implementation of #9737. Removed about 30% of the text. Might be still a little too verbose / redundant but the gist is in it (in case anyone wants to read it - i have ;)).
I used exclusively small/local models for this (27b, 8b and 4b) do evaluate if such thing is possible.
Status: implemented (build + unit tests green); the document
describes the initial draft implementation.
Idea: when a directory becomes a group in the Projects view, promote
the directory itself into a GenericProject — so it appears as a real
project node (logical view = all files, plus the full stock project menu).
The group becomes the stock SubprojectsGroup for the promoted project:
nested projects are still listed as project nodes, but discovery is
delegated to a ProjectContainerProvider in the project's lookup (see
below) instead of a hard-coded scan in projectui.
What DirectoryGroup is today
ide/projectui/src/org/netbeans/modules/project/ui/groups/DirectoryGroup.java:
- Created by the "New Group" dialog:
NewGroupPanel.java:221
(DirectoryGroup.create(name, f)). - Persisted in NbPreferences as
name/kind(KIND = "directory") /
path(directory URL) under the group node; re-instantiated by the
KINDswitch inGroup.load. findProjectsscans the directory for projects (with a
SharabilityQueryguard) — the group node lists nested project groups.
Files are never shown.- A group is per-user view state: everything lives in preferences, the
file tree is never touched.
The relationship reversal (at a glance)
Today the view knows the discovery policy — DirectoryGroup walks
the directory itself and hard-codes how projects are found. After the
change the project knows — the stock group simply asks the
project's lookup, and the policy lives in the project type. Push
becomes pull:
BEFORE — the view reaches into the directory (push)
projectui (view) filesystem
┌──────────────────────────────────┐
│ DirectoryGroup │
│ findProjects() │── directory scan ──▶ X/
│ processFolderChildren() │ ├── a/ (project)
│ walk folders, │ ├── b/
│ findProject(), │ │ └── c/ (project)
│ sharability guard, │ └── ...
│ convertor filter │
└──────────────────────────────────┘
↑ discovery policy hard-coded here
(view module knows how to find
projects in a directory)
projects inside are passive —
they know nothing about being listed
AFTER — the view asks the project (pull)
projectui (view) project type (generic.project)
┌────────────────────────────┐
│ SubprojectsGroup (stock, │
│ unchanged) │
│ findProjects() │
│ visitSubprojects(p) │
│ p.getLookup() │◄─────────────────────────┐
│ .lookup( │ "which projects do │
│ ProjectContainerProvider) │ you contain?" │
│ │ │
└────────────────────────────┘ │
│
┌───────────────────┴───────────────┐
│ GpProjectContainerProvider │
│ (in p's lookup, │
│ base-lookup inner class │
│ walks X/ + sharability + │
│ convertor filters │
└──────────────────────────────────┘
↑ discovery policy lives here
(in the project type)
The "New Group" flow through the promotion seam:
"New Group" → directory X
│
▼
NewGroupPanel (projectui)
│ Lookup.lookup(DirectoryProjectProvider.class)
▼
GpDirectoryProjectProvider (generic.project)
│
├── findProject(X) != null? ─── yes ──▶ return the existing project
├── X NOT_SHARABLE? ─────────── yes ──▶ null (declined)
└── set MARKER_ATTRIBUTE, clearNonProjectCache(), findProject(X)
│
▼
OpenProjectList.open({p}, …, asynchronously=true)
+ SubprojectsGroup.create(name, p)
The open uses the stock new-project-wizard pattern
OpenProjectList.open(projects, /*prime=*/false, /*openSubprojects=*/false, /*asynchronously=*/true, /*mainProject=*/null) — OpenProjects.requestOpen does not exist in
this tree — and runs off the EDT: GroupsMenu posts create() to a
request processor. A null result (decline), an absent provider, or an
IOException is a hard error at the seam (IllegalStateException /
AssertionError) — fail loud rather than fall back to a passive
directory group.
| before | after | |
|---|---|---|
| group class | DirectoryGroup |
SubprojectsGroup (stock) |
| discovery policy | hard-coded in projectui |
in the project's lookup |
| view → projects | push: view walks the dir | pull: view asks the project |
| nested projects | listed by blind scan | listed by the project itself |
| new project types | invisible to the mechanism | plug in their own provider |
Design
Semantics of the promotion
- Private (attribute-based) promotion, always for the group flow:
setGenericProject.MARKER_ATTRIBUTEon the directory'sFileObject
(stored in userdir'sattributes.xml: per-user, file tree untouched,
reversible). Rationale: a group is per-user state — a per-user UI
action must not mutate the file tree or make the folder a project for
every user on the machine. - Canonical instance: set attribute →
ProjectManager.findProject(dir).
Nevernew GenericProjectdirectly (two identities for one directory
would breakFileOwnerQuery, open lists, ...).
Gotcha (found by the tests):NbProjectManagernegatively
caches directories that were not projects — the idempotency
findProject(dir)call at the top of the promotion stores a
NO_SUCH_PROJECTentry that the secondfindProject(dir)would
honor. The stock wizard flow hits the same wall and solves it with
ProjectManager.clearNonProjectCache()("useful after creating
project metadata in a folder") — the promotion does the same between
setting the attribute and the final lookup. - Sharability guard: do not promote a
NOT_SHARABLEdirectory —
createProjectdeclines withnulland the seam reports the failure
(there is no passive fallback group anymore). Note this is the
flip side of theSharabilityQuerycheck infindProjects(which is
about reading projects out of non-sharable dirs) — keep the two
decisions distinct in comments. - Idempotent: if the directory already is a project (of any type),
return/open it instead of promoting. - Group removal does not clear the attribute — the folder remains
your project; it stays re-openable and is still deletable via the
stock "Delete" menu entry.
The seam: a lookup service, not a hard dependency
projectui must not depend on generic.project (a common project-UI
module depending on one specific project type is the wrong direction).
With zero new module dependencies (both sides already depend on
projectuiapi):
- New interface in
projectuiapi(package e.g.
org.netbeans.spi.project.ui.support):public interface DirectoryProjectProvider { /** * @return a project for the directory — the existing project if * the directory already is one, a newly promoted one if * this provider can make it so — or null if it cannot * handle it. */ Project createProject(FileObject dir) throws IOException; }
generic.projectregisters the implementation via
@ServiceProvider(no new dependency):
findProject(dir) != null→ return it; else sharability check, set
MARKER_ATTRIBUTE, returnfindProject(dir). This is the same
primitive asGenericProjectAPI.markAsLocalProject(dir)from the
API-module design — the two conversations unify on it.projectuichanges the call site (NewGroupPanel:221): ask the
LookupforDirectoryProjectProvider; non-null result → open the
project and create a stockSubprojectsGroup.create(name, p)
(KIND = "subprojects" — already inGroup.load's switch);null
(decline), absent provider, orIOException→ hard error
(IllegalStateException/AssertionError) — fail loud rather than
fall back to a passive directory group.
No dependency cycle: generic.project → projectuiapi and
projectui → projectuiapi both already exist; projectui never imports
our classes.
Why there is no shared boolean anywhere near the seam
- The group flow is essentially per-user, so it only ever wants the
private variant; asharedflag oncreateProjectwould be dead
weight in the SPI until some hypothetical "New Group" checkbox asks
for it. - The shared/private axis is expressed at the API level as two named
methods, not a boolean:GenericProjectAPI.markAsLocalProject(dir); // attribute, per-user GenericProjectAPI.markAsGenericProject(dir); // marker file, shared
- The one place a creation-time choice makes sense is the explicit
"Convert to Generic Project" command on a never-project folder — the
wizard's job, which already offers it. That command calls the API
directly and never goes through the SPI.
Private by default, share on demand
The upgrade path is already implemented and cheap ("two clicks"):
GenericProject.setMarkerState(dir, true) is a symmetric toggle that
creates the marker before clearing the redundant attribute (and sets
the attribute before deleting the marker on the way down), so the
directory is never in an unrecognized state. Recognition is the OR of
both mechanisms, so the project's identity never changes — same
GenericProject instance, no reload, no IDE restart.
- Default to the reversible side: private→shared is lossless (nothing
in the tree changed). - Follow-up (separate, trivial): a "Make Shared / Make Private" menu
entry — one line oversetMarkerState.
Nested projects: the stock ProjectContainerProvider
ProjectContainerProvider (projectapi SPI, since 1.56 — "a less vague
variant of SubprojectProvider for code that wants to access projects
that the current project serves as container for") is exactly the seam
to delegate discovery to the project type. And the stock discovery
built on it already exists — no rewrite needed:
SubprojectsGroup.visitSubprojects (the group's findProjects) does,
per project:
- look up
ProjectContainerProviderin the project's lookup; - if present: add
getContainedProjects().getProjects(); if
!res.isRecursive(), re-visit the same project (see caveat below); - else fall back to the semi-deprecated
SubprojectProviderand
visit each returned project the same way.
So the promotion flow just creates a stock SubprojectsGroup for the
promoted project, and all discovery runs through our provider.
SubprojectsGroup persists the project directory URL (KIND
"subprojects", already handled by Group.load), falls back to the
project's display name (so our editable name shows), and resolves to an
empty group if the directory ever stops being a project.
Our provider — new generic.project class
GpProjectContainerProvider implements ProjectContainerProvider, SubprojectProvider:
- Registered as a base-lookup inner class of
GenericProject
(added to the project's baseLookupin the constructor) — not the
maven@ProjectServiceProviderlayer pattern: every other service in
this module is a base-lookup inner class, it replaces the previous
direct-children-onlyGpSubprojectProviderin place, and no layer
entry is needed for a class that exists for the life of the project
instance. - Implements both interfaces over the same set: the stock
OpenSubprojects("Open Required Projects" menu item) enables itself
on the presence of the oldSubprojectProvideronly. getContainedProjects()= a single pass over the project
directory's folders:getFolders(false),findProjectper folder,
skipProjectConvertors.isConvertorProject, skip the children of a
NOT_SHARABLEfolder (stop re-checking once the folder is proven
SHARABLE), exclude the project itself,Thread.interrupted()
checks (callers include the open-project dialog — maven does the
same).- Returns the projects directly contained in the directory,
reported as complete (recursive=false). A single pass over the
directory's folders: the deeper containment scan proved too slow
for large trees, so the exposed set is what the old
GpSubprojectProviderreported — projects nested deeper are not
exposed through this provider.
Maven already trips over this (returnsrecursive=false): a 3-level
maven tree shows only the first module level in a stock subprojects
group. (OpenProjectList.openhandlesrecursive=falsecorrectly
via itstoHandlequeue — the no-op is specific to
SubprojectsGroup.) ChangeSupportis present but never fires in v1 — no
FileChangeListeneris registered on the project directory. Rationale
(revisited at implementation time): no stock consumer registers a
listener today (maven's own TODO: "is someone actually currently
listening on SubprojectProvider changes?"), and a listener on the
project directory would pin the project instance as long as the
listener lives. The group re-queries whenever projects are opened or
closed; a silently added nested project may not show until the next
refresh — acceptable for v1.
Blast radius — every in-tree consumer of
ProjectContainerProvider, checked:
| consumer | effect for generic projects |
|---|---|
SubprojectsGroup (projectui) |
the intended use: the group lists nested projects |
OpenProjectList.open(…, openSubprojects=true, …) (projectui) |
"Open Required Projects" / open-dialog flows with the flag auto-open the project's contained projects. Plain open(p) (flag false) does not cascade |
ProjectChooserAccessory (open dialog, projectui) |
same pattern as above |
| maven debug/execute utils | type-gated to maven, no effect |
Resulting user experience
- "New Group → directory group" for folder X → X is promoted and
opened as a project (logical view = all files, full stock project
menu: New File, Find, Rename/Move/Copy/Delete, versioning, Customize).
The group is a stockSubprojectsGroupfor X, so the projects
contained in X appear as project nodes under it — the old
DirectoryGroupUX, minus the hard-coded scan. - Contained projects are visible in two places, as in any container setup:
as project nodes in the group, and as folders in X's file logical
view. Optional polish: aProjectNodeFactoryin the generic logical
view to render them as project nodes there too.
Files touched
| module | change |
|---|---|
projectuiapi |
+1 interface (DirectoryProjectProvider, promotion only); spec version 1.124 → 1.125; apichanges.xml entry (and migration of its DOCTYPE from DTD 1.0 to 1.1 — the 1.0 DTD's year list stops at 2025) |
projectui |
NewGroupPanel call site: ask the Lookup for DirectoryProjectProvider; on promotion, open the project (asynchronously, stock wizard pattern) and create SubprojectsGroup.create(name, p) instead of DirectoryGroup.create; DirectoryGroup + DirectoryGroupEditPanel (and their Bundle.properties keys) removed; legacy kind="directory" nodes migrated to subprojects in Group.load (see Pre-existing groups) |
generic.project |
+promoteToProject static, +GpDirectoryProjectProvider (@ServiceProvider), GpSubprojectProvider replaced by GpProjectContainerProvider (base lookup, single pass over the directory's folders), +org.netbeans.modules.queries and org.netbeans.modules.projectuiapi.base (for ProjectConvertors — the class lives in the base module, not in projectuiapi itself; spec 1.120, matching the built artifact) deps, 7 new unit tests (+1 rewritten) |
Note: this deliberately breaks the "projectui / projectuiapi are
read-only reference" rule from the generic.project work — by a small,
well-justified amount (one interface, one call-site rework).
Pre-existing groups
DirectoryGroup (class, edit panel, form, bundle keys) is removed;
legacy nodes are migrated on load. Group.load's KIND switch accepts
the legacy kind="directory" and runs a one-shot migration before
returning a stock SubprojectsGroup:
- the stored directory is resolved and passed to
ProjectManager.findProject; if that finds no project, a
DirectoryProjectProvideris looked up andcreateProject
(promotion) is attempted — so a legacy group on an already-project
directory is salvaged even in a distribution without
generic.project; - the node's
kindis rewritten to"subprojects"in all cases —
success and failure alike — so the migration runs once; a transient
failure (e.g. a one-time IO error) is not retried, the group stays
empty until the user removes it or creates a new one on the directory; - any failure (unresolvable path, no provider registered, provider
declined, IO error) degrades to an emptySubprojectsGroup—
visible in Manage Groups (re-pointable to any project, or deletable),
with one warning logged per group.
Activity
pressing-buttons commentedon Oct 21, 2026
Would this solve the issue with "resource only" sub projects in Gradle, or be usable as a workaround?
#8180
mbien commentedon Oct 21, 2026
@pressing-buttons well, it wouldn't solve it directly but it would be a workaround at least ;)
you can test it btw. the PRs workflow runs add dev-build zips on the bottom of the summary page. In essence: you would be able to open any folder as project.