jdaugherty commented on code in PR #432:
URL: 
https://github.com/apache/grails-intellij-plugin/pull/432#discussion_r4178183710


##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/nodes/OtherGrailsAppSourcesNode.java:
##########
@@ -96,7 +97,12 @@ public OtherGrailsAppSourcesNode(@NotNull PsiDirectory 
directory, @NotNull ViewS
     for (VirtualFile dir : otherDirs) {
       PsiDirectory directory = manager.findDirectory(dir);
       if (directory != null) {
-        result.add(new PsiDirectoryNode(project, directory, getSettings()));
+        if (GrailsViewItems.ASSETS_DIR.equals(dir.getName())) {
+          result.add(new PsiDirectoryNode(project, directory, getSettings(),
+                                          item -> 
!GrailsViewItems.isAssetSubfolder(item.getName())));

Review Comment:
   This filter matches by name, and `PsiDirectoryNode` passes its filter down 
to every child directory node (`ProjectViewDirectoryHelper` creates each 
subdirectory node with the same filter). So any folder or file named `images`, 
`stylesheets` or `javascripts` anywhere under `assets/` disappears, not just 
the three top-level ones. A common vendor layout such as 
`grails-app/assets/vendor/jquery-ui/images/` vanishes from the tree.
   
   `contains()` still returns `true` for 
`assets/vendor/jquery-ui/images/ui-icons.png`, because 
`isUnderHiddenChildDirectory` only looks at the direct children of `assets`. So 
Reveal in Project View expands Other sources and dead-ends on the hidden 
folder, which is the case this PR sets out to prevent.
   
   I confirmed both with a probe test: the `jquery-ui` node's children came 
back as `[jquery-ui.js]` only. Matching only the direct children of `assets` 
fixes it, and the existing tests still pass:
   
   ```java
   item -> {
     VirtualFile file = item.getVirtualFile();
     return file == null || !dir.equals(file.getParent()) || 
!GrailsViewItems.isAssetSubfolder(item.getName());
   }
   ```
   
   Please add a regression test for a nested `images/` folder as well.



##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/impl/Grails3NodeProvider.java:
##########
@@ -36,13 +42,22 @@
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import javax.swing.Icon;
 
 public class Grails3NodeProvider implements GrailsViewNodeProvider {
 
   private static final List<String> SPECIAL_FILES = List.of("build.gradle", 
"settings.gradle", "gradle.properties");
   private static final List<String> SPECIAL_DIRS = List.of("src/main/scripts", 
"src/main/webapp");
 
+  /**
+   * Grails 7 kebab-case test source roots under {@code src/}. The Grails 6 
camelCase roots
+   * ({@code integrationTest}, {@code functionalTest}) deliberately stay 
inside the {@code src} node.
+   */
+  private static final Set<String> TEST_SOURCE_DIRS = Set.of("test", 
"integration-test", "functional-test");

Review Comment:
   On 8.0.x, test phases aren't limited to these three names. 
`TestPhase.sourceFolderName` defaults to `src/<kebab-case phase name>` for any 
phase declared in `testPhases { }`, and `GrailsCliArtifactGradlePlugin` adds 
`src/integration-test-cli`. Those would stay buried under `src`.
   
   Deriving the lifted folders from the module's test source roots would cover 
them. For example, lift a child of `src` whose source roots are in test content 
(`ProjectFileIndex.isInTestSourceContent`, which the Gradle import already sets 
up). That's Community Edition API. This is fine as a follow-up if you'd rather 
keep the PR's scope as it is.



##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/impl/GrailsViewItems.java:
##########
@@ -47,16 +46,34 @@ public record SpecialFolder(@NotNull Icon icon, int weight, 
@NotNull String titl
 
   public static final Map<String, SpecialFolder> SPECIAL_GRAILS_APP_FOLDERS = 
specialFolders();
 
+  /** Subfolders of {@code grails-app/assets} shown as dedicated top-level 
nodes. */
+  public static final Map<String, SpecialFolder> SPECIAL_ASSET_FOLDERS = 
assetFolders();
+
+  /** Name of the {@code grails-app} directory whose subfolders are shown as 
dedicated nodes. */
+  public static final String ASSETS_DIR = "assets";
+
   private GrailsViewItems() {
   }
 
+  // Declaration order below is not the rendering order: GrailsNodeComparator 
sorts these nodes by
+  // their NodeWeights weight, so the two registries can be read in any order 
without affecting the tree.
   private static Map<String, SpecialFolder> specialFolders() {
-    // LinkedHashMap because the project view renders these in declaration 
order.
-    Map<String, SpecialFolder> result = new LinkedHashMap<>();
-    result.put("conf", new SpecialFolder(AllIcons.Nodes.ConfigFolder, 
NodeWeights.CONFIG_FOLDER, "Configuration"));
-    result.put("views", new SpecialFolder(GroovyMvcIcons.Gsp_logo, 
NodeWeights.VIEWS_FOLDER, "Views"));
-    result.put("init", new SpecialFolder(AllIcons.Nodes.ConfigFolder, 
NodeWeights.CONFIG_FOLDER - 1, "Initialization"));
-    return Map.copyOf(result);
+    return Map.of(
+      "conf", new SpecialFolder(AllIcons.Nodes.ConfigFolder, 
NodeWeights.CONFIG_FOLDER, "Configuration"),
+      "views", new SpecialFolder(GroovyMvcIcons.Gsp_logo, 
NodeWeights.VIEWS_FOLDER, "Views"),
+      "init", new SpecialFolder(AllIcons.Nodes.ConfigFolder, 
NodeWeights.CONFIG_FOLDER - 1, "Initialization"),
+      "i18n", new SpecialFolder(AllIcons.FileTypes.Properties, 
NodeWeights.TRANSLATIONS_FOLDER, "Translations"),
+      // utils is generated in the Grails 7 app skeleton; it holds user Codec 
classes.

Review Comment:
   grails-forge (start.grails.org) doesn't create `grails-app/utils`. On 8.0.x, 
`GrailsBase` only adds `src/main/groovy`, `src/test/groovy`, 
`src/integration-test/groovy`, `services`, `domain` and `taglib`. The 
`utils/.gitkeep` comes from the legacy `grails-profiles/web/skeleton`. 
Recognising `utils` is still worthwhile, but this comment gives the wrong 
reason.



##########
plugin/src/test/java/org/apache/grails/intellij/plugin/projectView/impl/GrailsAppNodeProviderTest.java:
##########
@@ -0,0 +1,260 @@
+/*
+ * 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
+ *
+ *   https://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.apache.grails.intellij.plugin.projectView.impl;
+
+import com.intellij.icons.AllIcons;
+import com.intellij.ide.projectView.PresentationData;
+import com.intellij.ide.projectView.ViewSettings;
+import com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode;
+import com.intellij.ide.projectView.impl.nodes.PsiFileSystemItemFilter;
+import com.intellij.ide.util.treeView.AbstractTreeNode;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiDirectory;
+import com.intellij.psi.PsiManager;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import 
org.apache.grails.intellij.plugin.artefact.api.GrailsDisplayableArtefactHandler;
+import 
org.apache.grails.intellij.plugin.artefact.impl.ControllerArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.DomainArtefactHandler;
+import 
org.apache.grails.intellij.plugin.artefact.impl.InterceptorArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.ServiceArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.TaglibArtefactHandler;
+import org.apache.grails.intellij.plugin.projectView.NodeWeights;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsApplicationNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsArtefactHandlerNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsPsiDirectoryNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.OtherGrailsAppSourcesNode;
+import org.apache.grails.intellij.plugin.structure.GrailsApplication;
+
+import java.util.Collection;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.swing.Icon;
+
+public class GrailsAppNodeProviderTest extends GrailsNodeProviderTestSupport {
+
+  public void testRendersTranslationsAndAssetSubfolderNodes() {
+    addAssetAndTranslationFixture();
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    GrailsPsiDirectoryNode translations = findNode(nodes, "i18n");
+    assertNotNull("Translations node must be present", translations);
+    assertEquals(NodeWeights.TRANSLATIONS_FOLDER, 
translations.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Properties, translations.getNodeIcon());
+
+    GrailsPsiDirectoryNode stylesheets = findNode(nodes, "stylesheets");
+    assertNotNull("Stylesheets node must be present", stylesheets);
+    assertEquals(NodeWeights.STYLESHEETS_FOLDER, stylesheets.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Css, stylesheets.getNodeIcon());
+
+    GrailsPsiDirectoryNode images = findNode(nodes, "images");
+    assertNotNull("Images node must be present", images);
+    assertEquals(NodeWeights.IMAGES_FOLDER, images.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Image, images.getNodeIcon());
+
+    GrailsPsiDirectoryNode javascripts = findNode(nodes, "javascripts");
+    assertNotNull("JavaScripts node must be present", javascripts);
+    assertEquals(NodeWeights.JAVASCRIPTS_FOLDER, javascripts.getNodeWeight());
+    assertSame(AllIcons.FileTypes.JavaScript, javascripts.getNodeIcon());
+
+    GrailsPsiDirectoryNode utils = findNode(nodes, "utils");
+    assertNotNull("Utils node must be present", utils);
+    assertEquals(NodeWeights.UTILS_FOLDER, utils.getNodeWeight());
+    assertSame(AllIcons.Nodes.Class, utils.getNodeIcon());
+
+    GrailsPsiDirectoryNode migrations = findNode(nodes, "migrations");
+    assertNotNull("Migrations node must be present", migrations);
+    assertEquals(NodeWeights.MIGRATIONS_FOLDER, migrations.getNodeWeight());
+    assertSame(AllIcons.Nodes.DataSchema, migrations.getNodeIcon());
+  }
+
+  public void testRendersTitlesForNewNodes() {
+    addAssetAndTranslationFixture();
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    assertPresentation(findNode(nodes, "i18n"), "Translations", 
AllIcons.FileTypes.Properties);
+    assertPresentation(findNode(nodes, "utils"), "Utils", 
AllIcons.Nodes.Class);
+    assertPresentation(findNode(nodes, "migrations"), "Migrations", 
AllIcons.Nodes.DataSchema);
+    assertPresentation(findNode(nodes, "images"), "Images", 
AllIcons.FileTypes.Image);
+    assertPresentation(findNode(nodes, "javascripts"), "JavaScripts", 
AllIcons.FileTypes.JavaScript);
+    assertPresentation(findNode(nodes, "stylesheets"), "Stylesheets", 
AllIcons.FileTypes.Css);
+  }
+
+  public void testMissingAssetSubfolderProducesNoNode() {
+    myFixture.addFileToProject("grails-app/i18n/messages.properties", "a=b");
+    myFixture.addFileToProject("grails-app/assets/stylesheets/app.css", "h1 
{}");
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    assertNotNull("Stylesheets node must be present", findNode(nodes, 
"stylesheets"));
+    assertNull("no Images node when assets/images is absent (FR7)", 
findNode(nodes, "images"));

Review Comment:
   Nit: `FR4`/`FR7`/`FR8` here and further down refer to a requirements doc 
that isn't in the repo. Please drop them from the assertion messages.



##########
plugin/src/test/java/org/apache/grails/intellij/plugin/projectView/impl/GrailsAppNodeProviderTest.java:
##########
@@ -0,0 +1,260 @@
+/*
+ * 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
+ *
+ *   https://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.apache.grails.intellij.plugin.projectView.impl;
+
+import com.intellij.icons.AllIcons;
+import com.intellij.ide.projectView.PresentationData;
+import com.intellij.ide.projectView.ViewSettings;
+import com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode;
+import com.intellij.ide.projectView.impl.nodes.PsiFileSystemItemFilter;
+import com.intellij.ide.util.treeView.AbstractTreeNode;
+import com.intellij.openapi.project.Project;
+import com.intellij.psi.PsiDirectory;
+import com.intellij.psi.PsiManager;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import 
org.apache.grails.intellij.plugin.artefact.api.GrailsDisplayableArtefactHandler;
+import 
org.apache.grails.intellij.plugin.artefact.impl.ControllerArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.DomainArtefactHandler;
+import 
org.apache.grails.intellij.plugin.artefact.impl.InterceptorArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.ServiceArtefactHandler;
+import org.apache.grails.intellij.plugin.artefact.impl.TaglibArtefactHandler;
+import org.apache.grails.intellij.plugin.projectView.NodeWeights;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsApplicationNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsArtefactHandlerNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.GrailsPsiDirectoryNode;
+import 
org.apache.grails.intellij.plugin.projectView.nodes.OtherGrailsAppSourcesNode;
+import org.apache.grails.intellij.plugin.structure.GrailsApplication;
+
+import java.util.Collection;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.swing.Icon;
+
+public class GrailsAppNodeProviderTest extends GrailsNodeProviderTestSupport {
+
+  public void testRendersTranslationsAndAssetSubfolderNodes() {
+    addAssetAndTranslationFixture();
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    GrailsPsiDirectoryNode translations = findNode(nodes, "i18n");
+    assertNotNull("Translations node must be present", translations);
+    assertEquals(NodeWeights.TRANSLATIONS_FOLDER, 
translations.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Properties, translations.getNodeIcon());
+
+    GrailsPsiDirectoryNode stylesheets = findNode(nodes, "stylesheets");
+    assertNotNull("Stylesheets node must be present", stylesheets);
+    assertEquals(NodeWeights.STYLESHEETS_FOLDER, stylesheets.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Css, stylesheets.getNodeIcon());
+
+    GrailsPsiDirectoryNode images = findNode(nodes, "images");
+    assertNotNull("Images node must be present", images);
+    assertEquals(NodeWeights.IMAGES_FOLDER, images.getNodeWeight());
+    assertSame(AllIcons.FileTypes.Image, images.getNodeIcon());
+
+    GrailsPsiDirectoryNode javascripts = findNode(nodes, "javascripts");
+    assertNotNull("JavaScripts node must be present", javascripts);
+    assertEquals(NodeWeights.JAVASCRIPTS_FOLDER, javascripts.getNodeWeight());
+    assertSame(AllIcons.FileTypes.JavaScript, javascripts.getNodeIcon());
+
+    GrailsPsiDirectoryNode utils = findNode(nodes, "utils");
+    assertNotNull("Utils node must be present", utils);
+    assertEquals(NodeWeights.UTILS_FOLDER, utils.getNodeWeight());
+    assertSame(AllIcons.Nodes.Class, utils.getNodeIcon());
+
+    GrailsPsiDirectoryNode migrations = findNode(nodes, "migrations");
+    assertNotNull("Migrations node must be present", migrations);
+    assertEquals(NodeWeights.MIGRATIONS_FOLDER, migrations.getNodeWeight());
+    assertSame(AllIcons.Nodes.DataSchema, migrations.getNodeIcon());
+  }
+
+  public void testRendersTitlesForNewNodes() {
+    addAssetAndTranslationFixture();
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    assertPresentation(findNode(nodes, "i18n"), "Translations", 
AllIcons.FileTypes.Properties);
+    assertPresentation(findNode(nodes, "utils"), "Utils", 
AllIcons.Nodes.Class);
+    assertPresentation(findNode(nodes, "migrations"), "Migrations", 
AllIcons.Nodes.DataSchema);
+    assertPresentation(findNode(nodes, "images"), "Images", 
AllIcons.FileTypes.Image);
+    assertPresentation(findNode(nodes, "javascripts"), "JavaScripts", 
AllIcons.FileTypes.JavaScript);
+    assertPresentation(findNode(nodes, "stylesheets"), "Stylesheets", 
AllIcons.FileTypes.Css);
+  }
+
+  public void testMissingAssetSubfolderProducesNoNode() {
+    myFixture.addFileToProject("grails-app/i18n/messages.properties", "a=b");
+    myFixture.addFileToProject("grails-app/assets/stylesheets/app.css", "h1 
{}");
+
+    Collection<AbstractTreeNode<?>> nodes = new 
GrailsAppNodeProvider().createNodes(testApplication(true), 
ViewSettings.DEFAULT);
+
+    assertNotNull("Stylesheets node must be present", findNode(nodes, 
"stylesheets"));
+    assertNull("no Images node when assets/images is absent (FR7)", 
findNode(nodes, "images"));
+    assertNull("no JavaScripts node when assets/javascripts is absent (FR7)", 
findNode(nodes, "javascripts"));
+    assertNull("no fake fonts node", findNode(nodes, "fonts"));
+    assertNull("no Migrations node when grails-app/migrations is absent",
+               findNode(nodes, "migrations"));
+  }
+
+  public void 
testOtherGrailsAppSourcesNodeExcludesTranslationsAndAssetSubfolders() {
+    addAssetAndTranslationFixture();
+
+    GrailsApplication application = testApplication(true);
+    PsiDirectory appRoot = 
PsiManager.getInstance(application.getProject()).findDirectory(application.getAppRoot());
+    assertNotNull(appRoot);
+
+    OtherGrailsAppSourcesNode node = new OtherGrailsAppSourcesNode(appRoot, 
ViewSettings.DEFAULT);
+    node.setParent(new GrailsApplicationNode(application, 
ViewSettings.DEFAULT));
+
+    Collection<AbstractTreeNode<?>> children = node.getChildrenImpl();
+    Set<String> childNames = children.stream()
+      .map(child -> child.getValue() instanceof PsiDirectory directory
+                    ? directory.getName() : String.valueOf(child.getValue()))
+      .collect(Collectors.toSet());
+    assertFalse("i18n must not duplicate under Other sources", 
childNames.contains("i18n"));
+    assertFalse("utils must not duplicate under Other sources", 
childNames.contains("utils"));
+    assertFalse("migrations must not duplicate under Other sources", 
childNames.contains("migrations"));
+
+    AbstractTreeNode<?> assetsChild = children.stream()
+      .filter(child -> child instanceof PsiDirectoryNode)
+      .map(child -> (PsiDirectoryNode)child)
+      .filter(child -> "assets".equals(child.getValue().getName()))
+      .findFirst()
+      .orElse(null);
+    assertNotNull("assets must remain under Other sources for stray files", 
assetsChild);
+
+    PsiFileSystemItemFilter filter = 
((PsiDirectoryNode)assetsChild).getFilter();
+    assertNotNull("assets node must carry a filter hiding the dedicated 
subfolders", filter);
+    PsiDirectory assetsDir = ((PsiDirectoryNode)assetsChild).getValue();
+    assertFalse("stylesheets subfolder hidden (FR4)", 
filter.shouldShow(assetsDir.findSubdirectory("stylesheets")));
+    assertFalse("images subfolder hidden (FR4)", 
filter.shouldShow(assetsDir.findSubdirectory("images")));
+    assertFalse("javascripts subfolder hidden (FR4)", 
filter.shouldShow(assetsDir.findSubdirectory("javascripts")));
+    assertTrue("stray file directly under assets stays visible (FR8)", 
filter.shouldShow(assetsDir.findFile("extra.txt")));
+  }
+
+  public void testContainsExcludesHiddenAssetSubfoldersAndSpecialFolders() {
+    addAssetAndTranslationFixture();
+    myFixture.addFileToProject("grails-app/views/index.gsp", "<html/>");
+    myFixture.addFileToProject("grails-app/assets/fonts/webfont.woff", 
"stray-font");
+
+    GrailsApplication application = testApplication(true);
+    PsiDirectory appRoot = 
PsiManager.getInstance(application.getProject()).findDirectory(application.getAppRoot());
+    assertNotNull(appRoot);
+
+    OtherGrailsAppSourcesNode node = new OtherGrailsAppSourcesNode(appRoot, 
ViewSettings.DEFAULT);
+    node.setParent(new GrailsApplicationNode(application, 
ViewSettings.DEFAULT));
+
+    assertFalse("css under a hidden asset subfolder must not be claimed 
(reveal dead-end)",
+                
node.contains(myFixture.findFileInTempDir("grails-app/assets/stylesheets/app.css")));
+    assertFalse("messages under the extracted Translations folder must not be 
claimed",
+                
node.contains(myFixture.findFileInTempDir("grails-app/i18n/messages.properties")));
+    assertFalse("codecs under the extracted Utils folder must not be claimed",
+                
node.contains(myFixture.findFileInTempDir("grails-app/utils/ShoutyCodec.groovy")));
+    assertFalse("changelogs under the extracted Migrations folder must not be 
claimed",
+                
node.contains(myFixture.findFileInTempDir("grails-app/migrations/changelog.groovy")));
+    assertFalse("views are rendered as a dedicated node, not here",
+                
node.contains(myFixture.findFileInTempDir("grails-app/views/index.gsp")));
+    assertTrue("stray files directly under assets stay visible (FR8)",
+               
node.contains(myFixture.findFileInTempDir("grails-app/assets/extra.txt")));
+    assertTrue("non-hidden asset subfolders stay visible",
+               
node.contains(myFixture.findFileInTempDir("grails-app/assets/fonts/webfont.woff")));
+  }
+
+  public void testHandlerWeightOrderForComparator() {
+    Project project = getProject();
+    ViewSettings settings = ViewSettings.DEFAULT;
+    GrailsNodeComparator comparator = new GrailsNodeComparator(project, 
"GrailsView");
+
+    GrailsArtefactHandlerNode domains = handlerNode(project, 
DomainArtefactHandler.INSTANCE, settings);
+    GrailsArtefactHandlerNode services = handlerNode(project, 
ServiceArtefactHandler.INSTANCE, settings);
+    GrailsArtefactHandlerNode controllers = handlerNode(project, 
ControllerArtefactHandler.INSTANCE, settings);
+    GrailsArtefactHandlerNode interceptors = handlerNode(project, 
InterceptorArtefactHandler.INSTANCE, settings);
+    GrailsArtefactHandlerNode taglibs = handlerNode(project, 
TaglibArtefactHandler.INSTANCE, settings);
+
+    assertTrue("Domains must sort before Services", 
comparator.compare(domains, services) < 0);
+    assertTrue("Services must sort before Controllers (reorder)", 
comparator.compare(services, controllers) < 0);
+    assertTrue("Controllers must sort before Interceptors", 
comparator.compare(controllers, interceptors) < 0);
+    assertTrue("Interceptors must sort before Tag Libraries", 
comparator.compare(interceptors, taglibs) < 0);
+    assertTrue("Tag Libraries must sort last", comparator.compare(taglibs, 
domains) > 0);
+  }
+
+  public void testDirectoryWeightOrderForComparator() {

Review Comment:
   This test builds its nodes from the constants directly, so it checks the 
order of the constants rather than what the providers produce, and it leaves 
out Initialization. Sorting the output of `createNodes()` with 
`GrailsNodeComparator` and asserting the resulting order would test what the 
pane actually renders.



##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/impl/Grails3NodeProvider.java:
##########
@@ -63,8 +78,21 @@ public class Grails3NodeProvider implements 
GrailsViewNodeProvider {
 
     PsiDirectory src = GrailsViewItems.findPsiDirectory(application, "src");
     if (src != null) {
-      PsiFileSystemItemFilter filter = item -> !specialDirs.contains(item) && 
GrailsViewItems.shouldShowItem(item);
+      List<PsiDirectory> testDirs = findTestSourceDirectories(src);
+      // Directories that get their own node, so src can refuse to claim them. 
PsiDirectoryNode.contains()
+      // only applies a node's filter to the file and its immediate parent, so 
hiding the directories from

Review Comment:
   Small correction: `PsiDirectoryNode.contains()` applies the filter only to 
the file itself (it calls `findFile(file)` and `findDirectory(file)` on the 
same `VirtualFile`), not to its parent. The conclusion still holds: hiding a 
directory doesn't stop the node from claiming what's under it, so the ancestor 
check is the right fix.



##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/impl/Grails3NodeProvider.java:
##########
@@ -36,13 +42,22 @@
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import javax.swing.Icon;
 
 public class Grails3NodeProvider implements GrailsViewNodeProvider {
 
   private static final List<String> SPECIAL_FILES = List.of("build.gradle", 
"settings.gradle", "gradle.properties");
   private static final List<String> SPECIAL_DIRS = List.of("src/main/scripts", 
"src/main/webapp");
 
+  /**
+   * Grails 7 kebab-case test source roots under {@code src/}. The Grails 6 
camelCase roots

Review Comment:
   Grails has never generated camelCase test roots. 
`IntegrationTestGradlePlugin` has used `sourceFolderName = 
'src/integration-test'` since Grails 3; it's the same in grails-gradle-plugin 
5.3.1 (Grails 5/6) and on grails-core 8.0.x. So the kebab-case roots aren't 
specific to Grails 7, and `src/integrationTest` isn't a Grails 6 convention.
   
   The behaviour is right, because it applies to every Grails 3+ app. But this 
javadoc, `testCamelCaseTestRootsStayUnderSrc` and the commit message describe a 
layout no Grails version produces. Could you reword them? If the camelCase test 
stays, it could be framed as "folders that aren't Grails test roots stay under 
src".



##########
plugin/src/main/java/org/apache/grails/intellij/plugin/projectView/impl/Grails3NodeProvider.java:
##########
@@ -63,8 +78,21 @@ public class Grails3NodeProvider implements 
GrailsViewNodeProvider {
 
     PsiDirectory src = GrailsViewItems.findPsiDirectory(application, "src");
     if (src != null) {
-      PsiFileSystemItemFilter filter = item -> !specialDirs.contains(item) && 
GrailsViewItems.shouldShowItem(item);
+      List<PsiDirectory> testDirs = findTestSourceDirectories(src);
+      // Directories that get their own node, so src can refuse to claim them. 
PsiDirectoryNode.contains()
+      // only applies a node's filter to the file and its immediate parent, so 
hiding the directories from
+      // src is not enough: src would still claim their contents, and Reveal 
in Project View would expand
+      // src and dead-end. isAncestor(dir, dir, false) is true, so one check 
covers the directories too.
+      Set<VirtualFile> lifted = liftedDirectories(specialDirs, testDirs);
+      PsiFileSystemItemFilter filter = item -> !isUnder(lifted, 
item.getVirtualFile())
+        && GrailsViewItems.shouldShowItem(item);
       result.add(new GrailsPsiDirectoryNode(src, settings, 
NodeWeights.SRC_FOLDERS, filter));
+
+      for (PsiDirectory testDir : testDirs) {
+        Icon icon = "test".equals(testDir.getName()) ? 
PlatformIcons.TEST_SOURCE_FOLDER : GroovyMvcIcons.Grails_test;

Review Comment:
   These nodes have no title, so the pane shows `test`, `integration-test` and 
`functional-test` as siblings of `src`, which reads like top-level project 
folders. `OldGrailsNodeProvider` labels its equivalents `Tests:unit` and so on. 
Consider a title or a `src/test` location hint.



##########
plugin/src/test/java/org/apache/grails/intellij/plugin/projectView/NodeWeightsTest.java:
##########
@@ -0,0 +1,52 @@
+/*
+ * 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
+ *
+ *   https://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.apache.grails.intellij.plugin.projectView;
+
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * GrailsNodeComparator orders directory nodes by subtracting their weights, 
so two nodes sharing a

Review Comment:
   `GrailsNodeComparator` returns `leftDir.getNodeWeight() - 
rightDir.getNodeWeight()` directly, so tied nodes compare as 0; they don't fall 
through to the platform comparator.
   
   Two weights are also computed rather than declared in `NodeWeights`: 
Interceptors (`CONTROLLERS_FOLDER + 1` = 31) and Initialization (`CONFIG_FOLDER 
- 1` = 59). A new constant at 31 or 59 would tie with them without this test 
noticing. Making them named constants (e.g. `INTERCEPTORS_FOLDER`, 
`INIT_FOLDER`) would let the test cover them.



##########
AGENTS.md:
##########
@@ -118,7 +118,7 @@ is a pure aggregator — it owns only RAT and coverage 
aggregation, no sources.
 
 | Path | Gradle project | Description |
 |------|----------------|-------------|
-| `plugin/` | `:plugin` | Main plugin: GSP language, Grails project support, 
run configs. Compiles against the Community Edition API only |
+| `plugin/` | `:plugin` | Main plugin: GSP language, Grails project support, 
run configs. Compiles against the Community Edition API only. Its Grails 3+ 
pane surfaces dedicated **Stylesheets**/**Images**/**JavaScripts**, 
**Migrations**, **Translations**, **Utils** and separated Grails 7 test-root 
nodes |

Review Comment:
   This table describes the module layout for agents working on the build, so a 
feature list doesn't belong here. I'd drop this change. The README mention is 
fine, although the inserted clause makes that sentence hard to read.



##########
IMPROVEMENT-PLAN.md:
##########
@@ -195,6 +200,16 @@ Detection must work from Gradle dependency data (the 
plugin already has a
       init,utils,assets}` plus `src/main/groovy`, `src/test/groovy`,
       `src/integration-test/groovy` (registered by `TestPhasesGradlePlugin`) 
and
       `src/functional-test/groovy` (functional/Geb phase) source sets.
+      **Ground truth (grails-core `8.0.x`, checked 2026-10-01):** the 
generated web skeleton is

Review Comment:
   This is backwards for the generator 0.2 uses. On 8.0.x, grails-forge's 
`GrailsApplication` feature writes 
`grails-app/init/{packagePath}/Application.groovy` and `BootStrap.groovy`, so 
`init/` **is** generated, and `GrailsBase` creates no `grails-app/utils`. The 
"`utils` but no `init`" picture comes from the legacy 
`grails-profiles/web/skeleton`, and even there `grails-profiles/base/skeleton` 
contributes `grails-app/init/.../Application.groovy`. Could you correct this 
paragraph?



##########
IMPROVEMENT-PLAN.md:
##########
@@ -98,9 +98,14 @@ that Grails 3+ actually uses.
 3. Grails **6.x** (legacy baseline, for the legacy plugin's regression record).
 
 Walk this checklist per app and record works / broken / missing:
-- [ ] Project recognized as Grails; `grails-app/*` project view pane renders
-      
controllers/domain/services/taglib/views/conf/i18n/**init**/**utils**/assets
-      groups (init/ and utils/ are conventions the plugin may predate).
+- [x] Project recognized as Grails; `grails-app/*` project view pane renders

Review Comment:
   Section 0.2 is a walkthrough done per app: generate 7.0.x, 8.0.0-SNAPSHOT 
and 6.x apps and record what works, what's broken and what's missing. I'd leave 
this box unticked until that's done. The "Shipped" note fits better under 2.2.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to