From 2cdc2858d738478c3627f395b12fc351808ffbfa Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 3 Sep 2026 17:22:33 +0800 Subject: [PATCH 1/5] fix: merge generated source roots into build output tree --- .../jdtls/ext/core/PackageCommand.java | 193 ++++++++++++++++-- .../jdtls/ext/core/PackageParams.java | 10 + src/java/jdtls.ts | 11 +- .../java-dep-generated-sources-tree.yaml | 156 ++++++++++++++ test/generated-sources-suite/index.ts | 38 ++++ .../projectView.test.ts | 150 ++++++++++++++ test/generated-sources/pom.xml | 19 ++ test/generated-sources/target/build-info.txt | 1 + .../com/example/generated/GeneratedApp.java | 4 + test/index.ts | 11 + 10 files changed, 569 insertions(+), 24 deletions(-) create mode 100644 test/e2e-plans/java-dep-generated-sources-tree.yaml create mode 100644 test/generated-sources-suite/index.ts create mode 100644 test/generated-sources-suite/projectView.test.ts create mode 100644 test/generated-sources/pom.xml create mode 100644 test/generated-sources/target/build-info.txt create mode 100644 test/generated-sources/target/generated-sources/demo/com/example/generated/GeneratedApp.java diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java index 9baf72fb..5f6f4685 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java @@ -126,6 +126,7 @@ public static List resolvePath(List arguments, IProgressMon throw new IllegalArgumentException("Should have one argument for resolvePath"); } String typeRootUri = (String) arguments.get(0); + boolean mergeBuildOutputSourceRoots = arguments.size() > 1 && Boolean.TRUE.equals(arguments.get(1)); List result = new ArrayList<>(); URI uri = JDTUtils.toURI(typeRootUri); ITypeRoot typeRoot = ExtUtils.JDT_SCHEME.equals(uri.getScheme()) ? JDTUtils.resolveClassFile(uri) : JDTUtils.resolveCompilationUnit(uri); @@ -143,12 +144,7 @@ public static List resolvePath(List arguments, IProgressMon if (isClassFile) { result.add(PackageNode.createNodeForVirtualContainer(pkgRoot)); } - // for invisible project, removing the '_' link name may cause an empty named package root - // in this case, we will avoid that 'empty' node from displaying - PackageNode pkgRootNode = PackageNode.createNodeForPackageFragmentRoot(pkgRoot); - if (StringUtils.isNotBlank(pkgRootNode.getName())) { - result.add(pkgRootNode); - } + addPackageFragmentRootPath(result, pkgRoot, mergeBuildOutputSourceRoots); if (!packageFragment.isDefaultPackage()) { result.add(PackageNode.createNodeForPackageFragment(packageFragment)); } @@ -158,7 +154,7 @@ public static List resolvePath(List arguments, IProgressMon IPackageFragmentRoot pkgRoot = resource.getPackageFragmentRoot(); result.add(PackageNode.createNodeForProject(pkgRoot)); result.add(PackageNode.createNodeForVirtualContainer(resource.getPackageFragmentRoot())); - result.add(PackageNode.createNodeForPackageFragmentRoot(pkgRoot)); + addPackageFragmentRootPath(result, pkgRoot, mergeBuildOutputSourceRoots); if (resource.getParent() instanceof IPackageFragment) { IPackageFragment packageFragment = (IPackageFragment) resource.getParent(); if (!packageFragment.isDefaultPackage()) { @@ -193,12 +189,7 @@ public static List resolvePath(List arguments, IProgressMon result.add(PackageNode.createNodeForProject(packageFragment)); IPackageFragmentRoot pkgRoot = (IPackageFragmentRoot) packageFragment.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT); - // for invisible project, removing the '_' link name may cause an empty named package root - // in this case, we will avoid that 'empty' node from displaying - PackageNode pkgRootNode = PackageNode.createNodeForPackageFragmentRoot(pkgRoot); - if (StringUtils.isNotBlank(pkgRootNode.getName())) { - result.add(pkgRootNode); - } + addPackageFragmentRootPath(result, pkgRoot, mergeBuildOutputSourceRoots); if (!packageFragment.isDefaultPackage()) { result.add(PackageNode.createNodeForPackageFragment(packageFragment)); } @@ -207,7 +198,7 @@ public static List resolvePath(List arguments, IProgressMon item.setUri(JDTUtils.getFileURI(resource)); result.add(item); } else { - return getParentAncestorNodes(resource); + return getParentAncestorNodes(resource, mergeBuildOutputSourceRoots); } } else { IContainer container = JDTUtils.findFolder(typeRootUri); @@ -227,7 +218,8 @@ public static List resolvePath(List arguments, IProgressMon * @return parent node list of element * @throws JavaModelException when fails to get path or resource */ - private static List getParentAncestorNodes(IResource element) throws JavaModelException { + private static List getParentAncestorNodes(IResource element, + boolean mergeBuildOutputSourceRoots) throws JavaModelException { List nodeList = new LinkedList<>(); while (element != null && !(element instanceof IWorkspaceRoot)) { IJavaElement javaElement = JavaCore.create(element); @@ -235,10 +227,11 @@ private static List getParentAncestorNodes(IResource element) throw nodeList.add(0, PackageNode.createNodeForProject(javaElement)); } else if (javaElement instanceof IPackageFragmentRoot) { IPackageFragmentRoot pkgRoot = (IPackageFragmentRoot) javaElement; - nodeList.add(0, new PackageRootNode(pkgRoot, - element.getProjectRelativePath().toPortableString(), NodeKind.PACKAGEROOT)); - nodeList.add(0, PackageNode.createNodeForProject(javaElement)); - return nodeList; + List prefix = new ArrayList<>(); + prefix.add(PackageNode.createNodeForProject(javaElement)); + addPackageFragmentRootPath(prefix, pkgRoot, mergeBuildOutputSourceRoots); + prefix.addAll(nodeList); + return prefix; } else if (javaElement instanceof IPackageFragment) { IPackageFragment packageFragment = (IPackageFragment) javaElement; if (packageFragment.containsJavaResources() || packageFragment.getNonJavaResources().length > 0) { @@ -303,6 +296,9 @@ private static List getProjectChildren(PackageParams query, IProgre ResourceVisitor visitor = new JavaResourceVisitor(javaProject); resourceSet.accept(visitor); List result = visitor.getNodes(); + if (query.isMergeBuildOutputSourceRoots()) { + mergeBuildOutputSourceRoots(result, javaProject); + } // Invisible project will always have the referenced libraries entry if (!ProjectUtils.isVisibleProject(project) || hasReferencedLibraries) { @@ -311,6 +307,154 @@ private static List getProjectChildren(PackageParams query, IProgre return result; } + private static void mergeBuildOutputSourceRoots(List nodes, IJavaProject javaProject) { + if (javaProject == null) { + return; + } + + try { + IFolder buildOutputRoot = getVisibleBuildOutputRoot(javaProject); + if (buildOutputRoot == null) { + return; + } + + Set sourceRootPaths = findSourceRootsUnder(javaProject, buildOutputRoot.getFullPath()).stream() + .map(IPackageFragmentRoot::getResource) + .filter(IFolder.class::isInstance) + .map(IResource::getFullPath) + .collect(Collectors.toSet()); + nodes.removeIf(node -> node.getKind() == NodeKind.PACKAGEROOT + && node.getPath() != null + && sourceRootPaths.contains(Path.fromPortableString(node.getPath()))); + } catch (JavaModelException e) { + JdtlsExtActivator.logException("Failed to merge generated source roots into the build output folder", e); + } + } + + private static void addBuildOutputSourceRootChildren(List nodes, IJavaProject javaProject, + IFolder folder) throws JavaModelException { + if (javaProject == null) { + return; + } + + IFolder buildOutputRoot = getVisibleBuildOutputRoot(javaProject); + IPath folderPath = folder.getFullPath(); + if (buildOutputRoot == null || !buildOutputRoot.getFullPath().isPrefixOf(folderPath)) { + return; + } + + Set existingPaths = nodes.stream() + .map(PackageNode::getPath) + .filter(StringUtils::isNotBlank) + .map(Path::fromPortableString) + .collect(Collectors.toSet()); + for (IPackageFragmentRoot sourceRoot : findSourceRootsUnder(javaProject, folderPath)) { + IResource sourceResource = sourceRoot.getResource(); + if (!(sourceResource instanceof IFolder)) { + continue; + } + + IPath sourcePath = sourceResource.getFullPath(); + if (folderPath.equals(sourcePath)) { + continue; + } + + IPath relativePath = sourcePath.removeFirstSegments(folderPath.segmentCount()); + IPath childPath = folderPath.append(relativePath.segment(0)); + if (existingPaths.contains(childPath)) { + continue; + } + + PackageNode childNode; + if (relativePath.segmentCount() == 1) { + PackageRootNode sourceRootNode = PackageNode.createNodeForPackageFragmentRoot(sourceRoot); + sourceRootNode.setDisplayName(relativePath.segment(0)); + childNode = sourceRootNode; + } else { + IFolder childFolder = ResourcesPlugin.getWorkspace().getRoot().getFolder(childPath); + if (!childFolder.exists()) { + continue; + } + childNode = PackageNode.createNodeForFolder(childFolder); + } + nodes.add(childNode); + existingPaths.add(childPath); + } + } + + private static void addPackageFragmentRootPath(List result, IPackageFragmentRoot packageRoot, + boolean mergeBuildOutputSourceRoots) throws JavaModelException { + PackageRootNode packageRootNode = PackageNode.createNodeForPackageFragmentRoot(packageRoot); + // Removing the invisible project's '_' link may produce an empty package root. + if (StringUtils.isBlank(packageRootNode.getName())) { + return; + } + + IResource packageRootResource = packageRoot.getResource(); + IFolder buildOutputRoot = mergeBuildOutputSourceRoots + ? getVisibleBuildOutputRoot(packageRoot.getJavaProject()) + : null; + if (buildOutputRoot != null && packageRootResource instanceof IFolder + && buildOutputRoot.getFullPath().isPrefixOf(packageRootResource.getFullPath()) + && !buildOutputRoot.getFullPath().equals(packageRootResource.getFullPath())) { + IPath packageRootPath = packageRootResource.getFullPath(); + IPath currentPath = buildOutputRoot.getFullPath(); + while (currentPath.segmentCount() < packageRootPath.segmentCount()) { + result.add(PackageNode.createNodeForFolder( + ResourcesPlugin.getWorkspace().getRoot().getFolder(currentPath))); + currentPath = currentPath.append(packageRootPath.segment(currentPath.segmentCount())); + } + packageRootNode.setDisplayName(packageRootResource.getName()); + } + result.add(packageRootNode); + } + + private static IFolder getVisibleBuildOutputRoot(IJavaProject javaProject) throws JavaModelException { + if (javaProject == null) { + return null; + } + + IPath projectPath = javaProject.getPath(); + IPath outputPath = javaProject.getOutputLocation(); + if (!projectPath.isPrefixOf(outputPath) || outputPath.segmentCount() <= projectPath.segmentCount()) { + return null; + } + + IPath buildOutputRootPath = projectPath.append(outputPath.segment(projectPath.segmentCount())); + for (Object resource : javaProject.getNonJavaResources()) { + if (!(resource instanceof IFolder) + || !buildOutputRootPath.equals(((IFolder) resource).getFullPath())) { + continue; + } + + List candidate = new ArrayList<>(); + candidate.add(resource); + ResourceVisitor visitor = new JavaResourceVisitor(javaProject); + new ResourceSet(candidate).accept(visitor); + if (!visitor.getNodes().isEmpty()) { + return (IFolder) resource; + } + } + return null; + } + + private static List findSourceRootsUnder(IJavaProject javaProject, IPath ancestorPath) + throws JavaModelException { + List result = new ArrayList<>(); + for (IClasspathEntry entry : javaProject.getRawClasspath()) { + if (entry.getEntryKind() != IClasspathEntry.CPE_SOURCE) { + continue; + } + for (IPackageFragmentRoot packageRoot : javaProject.findPackageFragmentRoots(entry)) { + IResource resource = packageRoot.getResource(); + if (resource instanceof IFolder && ancestorPath.isPrefixOf(resource.getFullPath())) { + result.add(packageRoot); + } + } + } + return result; + } + private static List getContainerChildren(PackageParams query, IProgressMonitor pm) { IJavaProject javaProject = getJavaProject(query.getProjectUri()); if (javaProject == null) { @@ -447,6 +591,7 @@ public static List getChildrenForPackage(IPackageFragment packageFragmen private static List getFolderChildren(PackageParams query, IProgressMonitor pm) { List children = new LinkedList<>(); IJavaProject javaProject = null; + IFolder folder = null; try { IPackageFragmentRoot packageRoot = getPackageFragmentRootFromQuery(query); if (packageRoot != null) { @@ -466,7 +611,7 @@ private static List getFolderChildren(PackageParams query, IProgres } } else { javaProject = packageRoot.getJavaProject(); - IFolder folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(Path.fromPortableString(query.getPath())); + folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(Path.fromPortableString(query.getPath())); if (folder.exists()) { boolean isJavaElement = JavaCore.create(folder) != null; children.addAll(Arrays.stream(folder.members()) @@ -477,7 +622,7 @@ private static List getFolderChildren(PackageParams query, IProgres } } else { // general resource folder. - IFolder folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(Path.fromPortableString(query.getPath())); + folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(Path.fromPortableString(query.getPath())); if (folder.exists()) { refreshLocal(folder, pm); children.addAll(Arrays.asList(folder.members())); @@ -488,7 +633,11 @@ private static List getFolderChildren(PackageParams query, IProgres ResourceSet resourceSet = new ResourceSet(children); ResourceVisitor visitor = new JavaResourceVisitor(javaProject); resourceSet.accept(visitor); - return visitor.getNodes(); + List result = visitor.getNodes(); + if (query.isMergeBuildOutputSourceRoots() && folder != null) { + addBuildOutputSourceRootChildren(result, javaProject, folder); + } + return result; } catch (CoreException e) { JdtlsExtActivator.logException("Problem load project classfile list ", e); diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java index 576563c5..1f1d006e 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java @@ -33,6 +33,8 @@ public class PackageParams { private boolean isHierarchicalView; + private boolean mergeBuildOutputSourceRoots; + /** * Optional list of resource URIs (sent by the client on auto-refresh) that * have just changed on disk. When present, the server only refreshes the @@ -52,6 +54,14 @@ public void setHierarchicalView(boolean isHierarchicalView) { this.isHierarchicalView = isHierarchicalView; } + public boolean isMergeBuildOutputSourceRoots() { + return mergeBuildOutputSourceRoots; + } + + public void setMergeBuildOutputSourceRoots(boolean mergeBuildOutputSourceRoots) { + this.mergeBuildOutputSourceRoots = mergeBuildOutputSourceRoots; + } + public PackageParams(NodeKind kind, String projectUri) { this.kind = kind; this.projectUri = projectUri; diff --git a/src/java/jdtls.ts b/src/java/jdtls.ts index 84a372a0..95183ee9 100644 --- a/src/java/jdtls.ts +++ b/src/java/jdtls.ts @@ -31,12 +31,14 @@ export namespace Jdtls { export async function getPackageData(params: IPackageDataParam): Promise { const uri: Uri | null = !params.projectUri ? null : Uri.parse(params.projectUri); const excludePatterns: {[key: string]: boolean} | undefined = workspace.getConfiguration("files", uri).get("exclude"); + const nonJavaResourcesFiltered: boolean = Settings.nonJavaResourcesFiltered(); + params.mergeBuildOutputSourceRoots = !nonJavaResourcesFiltered; let nodeData: INodeData[] = await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_GETPACKAGEDATA, params) || []; // check filter settings. - if (Settings.nonJavaResourcesFiltered()) { + if (nonJavaResourcesFiltered) { nodeData = nodeData.filter((data: INodeData) => { return data.kind !== NodeKind.Folder && data.kind !== NodeKind.File; }); @@ -65,7 +67,12 @@ export namespace Jdtls { } export async function resolvePath(params: string): Promise { - return await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_RESOLVEPATH, params) || []; + return await commands.executeCommand( + Commands.EXECUTE_WORKSPACE_COMMAND, + Commands.JAVA_RESOLVEPATH, + params, + !Settings.nonJavaResourcesFiltered(), + ) || []; } export async function getMainClasses(params: string): Promise { diff --git a/test/e2e-plans/java-dep-generated-sources-tree.yaml b/test/e2e-plans/java-dep-generated-sources-tree.yaml new file mode 100644 index 00000000..8b18c858 --- /dev/null +++ b/test/e2e-plans/java-dep-generated-sources-tree.yaml @@ -0,0 +1,156 @@ +# Test Plan: Java Dependency — Generated Sources Tree +# +# Verifies that a generated Java source root under Maven's physical target +# directory is rendered as one merged hierarchy when non-Java resources are +# shown, while Hide keeps the Java source root available at the project level. +# +# Usage: +# npx autotest run test/e2e-plans/java-dep-generated-sources-tree.yaml --vsix + +name: "Java Dependency — Generated Sources Tree" +description: | + Tests the merged Java Projects hierarchy for a physical Maven target folder + containing a generated Java source root and ordinary build output. + +setup: + extension: "redhat.java" + vscodeVersion: "stable" + workspace: "../generated-sources" + timeout: 180 + settings: + java.configuration.checkProjectSettingsExclusions: false + java.dependency.packagePresentation: "flat" + java.project.explorer.showNonJavaResources: true + workbench.startupEditor: "none" + +steps: + - id: "ls-ready" + action: "waitForLanguageServer" + timeout: 180 + + - id: "close-aux-bar" + action: "executeVSCodeCommand workbench.action.closeAuxiliaryBar" + verify: "Auxiliary bar closed" + + - id: "collapse-outline" + action: "collapseSidebarSection OUTLINE" + + - id: "collapse-timeline" + action: "collapseSidebarSection TIMELINE" + + - id: "collapse-workspace-root" + action: "collapseWorkspaceRoot" + + - id: "focus-java-projects" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verify: "Java Projects view is focused" + + - id: "wait-tree-load" + action: "wait 3 seconds" + + - id: "expand-project" + action: "expandTreeItem generated-sources-tree" + + - id: "verify-single-target" + action: "wait 1 seconds" + verifyTreeItem: + name: "target" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-generated-root-not-duplicated" + action: "wait 1 seconds" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "expand-target" + action: "expandTreeItem target" + + - id: "verify-build-info-visible" + action: "wait 1 seconds" + verifyTreeItem: + name: "build-info.txt" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "expand-generated-sources" + action: "expandTreeItem generated-sources" + + - id: "verify-nested-source-root" + action: "wait 1 seconds" + verifyTreeItem: + name: "demo" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "expand-source-root" + action: "expandTreeItem demo" + + - id: "verify-generated-package" + action: "wait 1 seconds" + verifyTreeItem: + name: "com.example.generated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "hide-non-java-resources" + action: 'clickViewTitleAction "Java Projects" "Hide Non-Java Resources"' + + - id: "wait-hide-refresh" + action: "wait 3 seconds" + + - id: "expand-project-after-hide" + action: "expandTreeItem generated-sources-tree" + + - id: "verify-physical-target-hidden" + action: "wait 1 seconds" + verifyTreeItem: + name: "target" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "verify-generated-root-kept" + action: "wait 1 seconds" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "show-non-java-resources" + action: 'clickViewTitleAction "Java Projects" "Show Non-Java Resources"' + + - id: "wait-show-refresh" + action: "wait 3 seconds" + + - id: "expand-project-after-show" + action: "expandTreeItem generated-sources-tree" + + - id: "verify-target-restored" + action: "wait 1 seconds" + verifyTreeItem: + name: "target" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-generated-root-merged-again" + action: "wait 1 seconds" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 diff --git a/test/generated-sources-suite/index.ts b/test/generated-sources-suite/index.ts new file mode 100644 index 00000000..348b80a2 --- /dev/null +++ b/test/generated-sources-suite/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as glob from "glob"; +import * as Mocha from "mocha"; +import * as path from "path"; + +export function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true, + timeout: 2 * 60 * 1000, + }); + + const testsRoot = __dirname; + + return new Promise((c, e) => { + glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { + if (err) { + return e(err); + } + + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); + + try { + mocha.run((failures) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + e(err); + } + }); + }); +} diff --git a/test/generated-sources-suite/projectView.test.ts b/test/generated-sources-suite/projectView.test.ts new file mode 100644 index 00000000..1752d4e8 --- /dev/null +++ b/test/generated-sources-suite/projectView.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { + Commands, + contextManager, + DataNode, + DependencyExplorer, + FileNode, + FolderNode, + Jdtls, + languageServerApiManager, + NodeKind, + PackageRootNode, + PrimaryTypeNode, + ProjectNode, +} from "../../extension.bundle"; +import { printNodes, setupTestEnv } from "../shared"; +import { sleep } from "../util"; + +// tslint:disable: only-arrow-functions +suite("Generated Source Tree Tests", () => { + let originalShowNonJavaResources: boolean | undefined; + + suiteSetup(async () => { + originalShowNonJavaResources = vscode.workspace.getConfiguration("java.project.explorer") + .inspect("showNonJavaResources")?.workspaceValue; + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + true, + vscode.ConfigurationTarget.Workspace, + ); + await setupTestEnv(); + await languageServerApiManager.ready(); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + await getProjectNode(); + }); + + suiteTeardown(async () => { + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + originalShowNonJavaResources, + vscode.ConfigurationTarget.Workspace, + ); + }); + + test("Merges generated source roots into the physical build output tree", async function() { + const projectNode = await getProjectNode(); + const projectChildren = await projectNode.getChildren(); + const targetFolder = projectChildren.find((node: DataNode) => + node instanceof FolderNode && node.name === "target") as FolderNode; + + assert.ok(targetFolder, `The physical target folder should be visible.\n${printNodes(projectChildren)}`); + assert.ok(!projectChildren.find((node: DataNode) => + node instanceof PackageRootNode && node.path?.endsWith("/target/generated-sources/demo")), + "The generated source root should not be duplicated at the project level"); + + const targetChildren = await targetFolder.getChildren(); + assert.ok(targetChildren.find((node: DataNode) => + node instanceof FileNode && node.name === "build-info.txt"), + "Non-Java build output should remain visible"); + + const generatedSourcesFolder = targetChildren.find((node: DataNode) => + node instanceof FolderNode && node.name === "generated-sources") as FolderNode; + assert.ok(generatedSourcesFolder, "The generated-sources ancestor should be visible under target"); + + const generatedSourcesChildren = await generatedSourcesFolder.getChildren(); + const demoRoot = generatedSourcesChildren.find((node: DataNode) => + node instanceof PackageRootNode && node.getDisplayName() === "demo") as PackageRootNode; + assert.ok(demoRoot, "The generated Java source root should be nested under its physical ancestors"); + assert.ok(demoRoot.path?.endsWith("/target/generated-sources/demo")); + }); + + test("Resolves Java files through the merged build output hierarchy", async function() { + const workspaceFolder = vscode.workspace.workspaceFolders![0]; + const generatedTypeUri = vscode.Uri.joinPath( + workspaceFolder.uri, + "target", + "generated-sources", + "demo", + "com", + "example", + "generated", + "GeneratedApp.java", + ); + const paths = await Jdtls.resolvePath(generatedTypeUri.toString()); + + assert.deepEqual(paths.slice(0, 4).map(path => path.kind), [ + NodeKind.Project, + NodeKind.Folder, + NodeKind.Folder, + NodeKind.PackageRoot, + ]); + assert.equal(paths[1].name, "target"); + assert.equal(paths[2].name, "generated-sources"); + assert.equal(paths[3].displayName, "demo"); + + const explorer = DependencyExplorer.getInstance(contextManager.context); + const revealedNode = await explorer.dataProvider.revealPaths(paths); + assert.ok(revealedNode instanceof PrimaryTypeNode, "The generated Java type should be revealable"); + assert.equal(revealedNode.name, "GeneratedApp"); + }); + + test("Keeps generated source roots visible when non-Java resources are hidden", async function() { + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + false, + vscode.ConfigurationTarget.Workspace, + ); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + + try { + const projectNode = await getProjectNode(); + const projectChildren = await projectNode.getChildren(); + assert.ok(!projectChildren.find((node: DataNode) => + node instanceof FolderNode && node.name === "target"), + "The physical target folder should be hidden"); + assert.ok(projectChildren.find((node: DataNode) => + node instanceof PackageRootNode && node.path?.endsWith("/target/generated-sources/demo")), + "The Java source root should remain visible at the project level"); + } finally { + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + true, + vscode.ConfigurationTarget.Workspace, + ); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + } + }); +}); + +async function getProjectNode(): Promise { + const explorer = DependencyExplorer.getInstance(contextManager.context); + const deadline = Date.now() + 60 * 1000; + let roots = await explorer.dataProvider.getChildren(); + while (Date.now() < deadline) { + const projectNode = roots?.find((node: DataNode) => + node instanceof ProjectNode && node.name === "generated-sources-tree") as ProjectNode; + if (projectNode) { + return projectNode; + } + await sleep(1000); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + roots = await explorer.dataProvider.getChildren(); + } + + assert.fail(`The generated-sources-tree project was not imported.\n${printNodes(roots || [])}`); +} diff --git a/test/generated-sources/pom.xml b/test/generated-sources/pom.xml new file mode 100644 index 00000000..427c02a0 --- /dev/null +++ b/test/generated-sources/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + com.example + generated-sources-tree + 1.0.0-SNAPSHOT + + + 17 + UTF-8 + + + + ${project.build.directory}/generated-sources/demo + + diff --git a/test/generated-sources/target/build-info.txt b/test/generated-sources/target/build-info.txt new file mode 100644 index 00000000..74373009 --- /dev/null +++ b/test/generated-sources/target/build-info.txt @@ -0,0 +1 @@ +Generated build metadata remains accessible from the physical target folder. diff --git a/test/generated-sources/target/generated-sources/demo/com/example/generated/GeneratedApp.java b/test/generated-sources/target/generated-sources/demo/com/example/generated/GeneratedApp.java new file mode 100644 index 00000000..3345b2ed --- /dev/null +++ b/test/generated-sources/target/generated-sources/demo/com/example/generated/GeneratedApp.java @@ -0,0 +1,4 @@ +package com.example.generated; + +public class GeneratedApp { +} diff --git a/test/index.ts b/test/index.ts index a08d7358..ff7da307 100644 --- a/test/index.ts +++ b/test/index.ts @@ -64,6 +64,17 @@ async function main(): Promise { ], }); + // Run test for generated Maven source roots inside the build output folder + await runTests({ + vscodeExecutablePath, + extensionDevelopmentPath, + extensionTestsPath: path.resolve(__dirname, "./generated-sources-suite"), + launchArgs: [ + path.join(__dirname, "..", "..", "test", "generated-sources"), + `--user-data-dir=${userDir}`, + ], + }); + // Run test for gradle project await runTests({ vscodeExecutablePath, From 158bf18fa9f759b539c965af928921d605d8349d Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 3 Sep 2026 17:33:41 +0800 Subject: [PATCH 2/5] test: stabilize generated source UI plan --- .../java-dep-generated-sources-tree.yaml | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/test/e2e-plans/java-dep-generated-sources-tree.yaml b/test/e2e-plans/java-dep-generated-sources-tree.yaml index 8b18c858..fda01315 100644 --- a/test/e2e-plans/java-dep-generated-sources-tree.yaml +++ b/test/e2e-plans/java-dep-generated-sources-tree.yaml @@ -13,7 +13,7 @@ description: | containing a generated Java source root and ordinary build output. setup: - extension: "redhat.java" + extension: "vscjava.vscode-java-pack" vscodeVersion: "stable" workspace: "../generated-sources" timeout: 180 @@ -69,20 +69,27 @@ steps: inView: "Java Projects" timeout: 15 - - id: "expand-target" - action: "expandTreeItem target" + # Opening the generated Java file lets link-with-editor reveal the complete + # merged hierarchy without an ambiguous "target" match in the File Explorer. + - id: "open-generated-file" + action: "open file GeneratedApp.java" + waitBefore: 2 - - id: "verify-build-info-visible" + - id: "collapse-workspace-root-after-generated-file" + action: "collapseWorkspaceRoot" + + - id: "focus-java-projects-after-generated-file" + action: "executeVSCodeCommand javaProjectExplorer.focus" + waitBefore: 2 + + - id: "verify-generated-sources-folder" action: "wait 1 seconds" verifyTreeItem: - name: "build-info.txt" + name: "generated-sources" exact: true inView: "Java Projects" timeout: 15 - - id: "expand-generated-sources" - action: "expandTreeItem generated-sources" - - id: "verify-nested-source-root" action: "wait 1 seconds" verifyTreeItem: @@ -91,9 +98,6 @@ steps: inView: "Java Projects" timeout: 15 - - id: "expand-source-root" - action: "expandTreeItem demo" - - id: "verify-generated-package" action: "wait 1 seconds" verifyTreeItem: @@ -102,6 +106,35 @@ steps: inView: "Java Projects" timeout: 15 + - id: "verify-generated-type" + action: "wait 1 seconds" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + # Reveal an ordinary build file to prove the physical target resources remain + # available alongside the generated Java source root. + - id: "open-build-info" + action: "open file build-info.txt" + waitBefore: 2 + + - id: "collapse-workspace-root-after-build-info" + action: "collapseWorkspaceRoot" + + - id: "focus-java-projects-after-build-info" + action: "executeVSCodeCommand javaProjectExplorer.focus" + waitBefore: 2 + + - id: "verify-build-info-visible" + action: "wait 1 seconds" + verifyTreeItem: + name: "build-info.txt" + exact: true + inView: "Java Projects" + timeout: 15 + - id: "hide-non-java-resources" action: 'clickViewTitleAction "Java Projects" "Hide Non-Java Resources"' @@ -132,7 +165,10 @@ steps: action: 'clickViewTitleAction "Java Projects" "Show Non-Java Resources"' - id: "wait-show-refresh" - action: "wait 3 seconds" + action: "wait 5 seconds" + + - id: "reset-tree-after-show" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" - id: "expand-project-after-show" action: "expandTreeItem generated-sources-tree" From 25a2f1d9456478a6e14d7877a2aea8d1def0cf75 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 8 Sep 2026 16:59:20 +0800 Subject: [PATCH 3/5] fix: preserve generated source roots when build output is excluded Apply the same visibility decision to tree layout and reveal paths, retain source-only record context after reparenting, and cover exclusions and layout transitions. --- .../jdtls/ext/core/PackageCommand.java | 105 +++--- .../jdtls/ext/core/PackageParams.java | 1 + .../jdtls/ext/core/model/PackageRootNode.java | 7 + src/java/jdtls.ts | 63 ++-- src/java/nodeData.ts | 2 + src/views/PrimaryTypeNode.ts | 17 +- ...java-dep-generated-sources-exclusions.yaml | 194 +++++++++++ .../java-dep-generated-sources-tree.yaml | 121 ++++++- .../projectView.test.ts | 301 +++++++++++++----- 9 files changed, 626 insertions(+), 185 deletions(-) create mode 100644 test/e2e-plans/java-dep-generated-sources-exclusions.yaml diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java index 5f6f4685..19d4b394 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java @@ -297,7 +297,7 @@ private static List getProjectChildren(PackageParams query, IProgre resourceSet.accept(visitor); List result = visitor.getNodes(); if (query.isMergeBuildOutputSourceRoots()) { - mergeBuildOutputSourceRoots(result, javaProject); + addBuildOutputSourceRootPaths(result, javaProject); } // Invisible project will always have the referenced libraries entry @@ -307,7 +307,7 @@ private static List getProjectChildren(PackageParams query, IProgre return result; } - private static void mergeBuildOutputSourceRoots(List nodes, IJavaProject javaProject) { + private static void addBuildOutputSourceRootPaths(List nodes, IJavaProject javaProject) { if (javaProject == null) { return; } @@ -318,16 +318,19 @@ private static void mergeBuildOutputSourceRoots(List nodes, IJavaPr return; } - Set sourceRootPaths = findSourceRootsUnder(javaProject, buildOutputRoot.getFullPath()).stream() - .map(IPackageFragmentRoot::getResource) - .filter(IFolder.class::isInstance) - .map(IResource::getFullPath) - .collect(Collectors.toSet()); - nodes.removeIf(node -> node.getKind() == NodeKind.PACKAGEROOT - && node.getPath() != null - && sourceRootPaths.contains(Path.fromPortableString(node.getPath()))); + // Keep the original root until the client has applied files.exclude + // to every node in its proposed build-output path. + for (PackageNode node : nodes) { + if (node instanceof PackageRootNode) { + IJavaElement element = JavaCore.create(node.getHandlerIdentifier()); + if (element instanceof IPackageFragmentRoot) { + ((PackageRootNode) node).setBuildOutputPath( + getBuildOutputSourceRootPath((IPackageFragmentRoot) element, buildOutputRoot)); + } + } + } } catch (JavaModelException e) { - JdtlsExtActivator.logException("Failed to merge generated source roots into the build output folder", e); + JdtlsExtActivator.logException("Failed to resolve generated source root display paths", e); } } @@ -349,36 +352,12 @@ private static void addBuildOutputSourceRootChildren(List nodes, IJ .map(Path::fromPortableString) .collect(Collectors.toSet()); for (IPackageFragmentRoot sourceRoot : findSourceRootsUnder(javaProject, folderPath)) { - IResource sourceResource = sourceRoot.getResource(); - if (!(sourceResource instanceof IFolder)) { - continue; - } - - IPath sourcePath = sourceResource.getFullPath(); - if (folderPath.equals(sourcePath)) { - continue; - } - - IPath relativePath = sourcePath.removeFirstSegments(folderPath.segmentCount()); - IPath childPath = folderPath.append(relativePath.segment(0)); - if (existingPaths.contains(childPath)) { - continue; - } - - PackageNode childNode; - if (relativePath.segmentCount() == 1) { - PackageRootNode sourceRootNode = PackageNode.createNodeForPackageFragmentRoot(sourceRoot); - sourceRootNode.setDisplayName(relativePath.segment(0)); - childNode = sourceRootNode; - } else { - IFolder childFolder = ResourcesPlugin.getWorkspace().getRoot().getFolder(childPath); - if (!childFolder.exists()) { - continue; + for (PackageNode childNode : getBuildOutputSourceRootPath(sourceRoot, buildOutputRoot)) { + IPath childPath = Path.fromPortableString(childNode.getPath()); + if (folderPath.equals(childPath.removeLastSegments(1)) && existingPaths.add(childPath)) { + nodes.add(childNode); } - childNode = PackageNode.createNodeForFolder(childFolder); } - nodes.add(childNode); - existingPaths.add(childPath); } } @@ -390,25 +369,43 @@ private static void addPackageFragmentRootPath(List result, IPackag return; } - IResource packageRootResource = packageRoot.getResource(); - IFolder buildOutputRoot = mergeBuildOutputSourceRoots - ? getVisibleBuildOutputRoot(packageRoot.getJavaProject()) - : null; - if (buildOutputRoot != null && packageRootResource instanceof IFolder - && buildOutputRoot.getFullPath().isPrefixOf(packageRootResource.getFullPath()) - && !buildOutputRoot.getFullPath().equals(packageRootResource.getFullPath())) { - IPath packageRootPath = packageRootResource.getFullPath(); - IPath currentPath = buildOutputRoot.getFullPath(); - while (currentPath.segmentCount() < packageRootPath.segmentCount()) { - result.add(PackageNode.createNodeForFolder( - ResourcesPlugin.getWorkspace().getRoot().getFolder(currentPath))); - currentPath = currentPath.append(packageRootPath.segment(currentPath.segmentCount())); - } - packageRootNode.setDisplayName(packageRootResource.getName()); + if (mergeBuildOutputSourceRoots && packageRoot.getKind() == IPackageFragmentRoot.K_SOURCE) { + packageRootNode.setBuildOutputPath(getBuildOutputSourceRootPath( + packageRoot, getVisibleBuildOutputRoot(packageRoot.getJavaProject()))); } result.add(packageRootNode); } + private static List getBuildOutputSourceRootPath(IPackageFragmentRoot packageRoot, + IFolder buildOutputRoot) throws JavaModelException { + IResource resource = packageRoot.getResource(); + if (buildOutputRoot == null || packageRoot.getKind() != IPackageFragmentRoot.K_SOURCE + || packageRoot.getRawClasspathEntry().getEntryKind() != IClasspathEntry.CPE_SOURCE + || !(resource instanceof IFolder)) { + return Collections.emptyList(); + } + + IPath rootPath = resource.getFullPath(); + IPath currentPath = buildOutputRoot.getFullPath(); + if (!currentPath.isPrefixOf(rootPath) || currentPath.equals(rootPath)) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + while (currentPath.segmentCount() < rootPath.segmentCount()) { + IFolder folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(currentPath); + if (!folder.exists()) { + return Collections.emptyList(); + } + result.add(PackageNode.createNodeForFolder(folder)); + currentPath = currentPath.append(rootPath.segment(currentPath.segmentCount())); + } + PackageRootNode rootNode = PackageNode.createNodeForPackageFragmentRoot(packageRoot); + rootNode.setDisplayName(resource.getName()); + result.add(rootNode); + return result; + } + private static IFolder getVisibleBuildOutputRoot(IJavaProject javaProject) throws JavaModelException { if (javaProject == null) { return null; diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java index 1f1d006e..72178018 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageParams.java @@ -33,6 +33,7 @@ public class PackageParams { private boolean isHierarchicalView; + // Request candidate display paths; the client decides whether their ancestors are visible. private boolean mergeBuildOutputSourceRoots; /** diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/model/PackageRootNode.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/model/PackageRootNode.java index d98439dd..1bbc925e 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/model/PackageRootNode.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/model/PackageRootNode.java @@ -11,6 +11,7 @@ package com.microsoft.jdtls.ext.core.model; +import java.util.List; import java.util.Map; import org.eclipse.jdt.core.IPackageFragmentRoot; @@ -22,6 +23,8 @@ public class PackageRootNode extends PackageNode { private Map attributes; + private List buildOutputPath; + public PackageRootNode(String name, String path, String uri, NodeKind kind, int entryKind) { super(name, path, kind); this.setUri(uri); @@ -48,4 +51,8 @@ public void setAttributes(Map attributes) { public Map getAttributes() { return this.attributes; } + + public void setBuildOutputPath(List buildOutputPath) { + this.buildOutputPath = buildOutputPath; + } } diff --git a/src/java/jdtls.ts b/src/java/jdtls.ts index 95183ee9..7855facc 100644 --- a/src/java/jdtls.ts +++ b/src/java/jdtls.ts @@ -29,50 +29,29 @@ export namespace Jdtls { } export async function getPackageData(params: IPackageDataParam): Promise { - const uri: Uri | null = !params.projectUri ? null : Uri.parse(params.projectUri); - const excludePatterns: {[key: string]: boolean} | undefined = workspace.getConfiguration("files", uri).get("exclude"); const nonJavaResourcesFiltered: boolean = Settings.nonJavaResourcesFiltered(); + const isVisible = createNodeVisibilityFilter(params.projectUri, nonJavaResourcesFiltered); params.mergeBuildOutputSourceRoots = !nonJavaResourcesFiltered; - let nodeData: INodeData[] = await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, + const nodeData: INodeData[] = await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_GETPACKAGEDATA, params) || []; - // check filter settings. - if (nonJavaResourcesFiltered) { - nodeData = nodeData.filter((data: INodeData) => { - return data.kind !== NodeKind.Folder && data.kind !== NodeKind.File; - }); - } - - if (excludePatterns && nodeData.length) { - const uriOfChildren: string[] = nodeData.map((node: INodeData) => node.uri).filter(Boolean) as string[]; - const urisToExclude: Set = new Set(); - for (const pattern in excludePatterns) { - if (excludePatterns[pattern]) { - const toExclude: string[] = minimatch.match(uriOfChildren, pattern); - toExclude.forEach((uriToExclude: string) => urisToExclude.add(uriToExclude)); - } - } - - if (urisToExclude.size) { - nodeData = nodeData.filter((node: INodeData) => { - if (!node.uri) { - return true; - } - return !urisToExclude.has(node.uri); - }); - } - } - return nodeData; + return nodeData.filter(node => isVisible(node) + && (params.kind !== NodeKind.Project || !getVisibleBuildOutputPath(node, isVisible))); } export async function resolvePath(params: string): Promise { - return await commands.executeCommand( + const nonJavaResourcesFiltered = Settings.nonJavaResourcesFiltered(); + const nodes: INodeData[] = await commands.executeCommand( Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_RESOLVEPATH, params, - !Settings.nonJavaResourcesFiltered(), + !nonJavaResourcesFiltered, ) || []; + const projectUri = nodes.find(node => node.kind === NodeKind.Project)?.uri; + const isVisible = createNodeVisibilityFilter(projectUri, nonJavaResourcesFiltered); + return nodes.reduce((result, node) => + result.concat(getVisibleBuildOutputPath(node, isVisible) || [node]), []); } export async function getMainClasses(params: string): Promise { @@ -105,6 +84,26 @@ export namespace Jdtls { } } +function createNodeVisibilityFilter(projectUri: string | undefined, nonJavaResourcesFiltered: boolean): (node: INodeData) => boolean { + const uri = projectUri ? Uri.parse(projectUri) : null; + const excludePatterns: {[key: string]: boolean} = workspace.getConfiguration("files", uri).get("exclude") || {}; + const patterns = Object.keys(excludePatterns).filter(pattern => excludePatterns[pattern]); + return node => { + if (nonJavaResourcesFiltered && (node.kind === NodeKind.Folder || node.kind === NodeKind.File)) { + return false; + } + const nodeUri = node.uri; + return !nodeUri || !patterns.some(pattern => minimatch(nodeUri, pattern)); + }; +} + +function getVisibleBuildOutputPath(node: INodeData, isVisible: (node: INodeData) => boolean): INodeData[] | undefined { + if (node.kind === NodeKind.PackageRoot && node.buildOutputPath?.length && node.buildOutputPath.every(isVisible)) { + return node.buildOutputPath; + } + return undefined; +} + interface IPackageDataParam { projectUri: string | undefined; [key: string]: any; diff --git a/src/java/nodeData.ts b/src/java/nodeData.ts index e2c35a46..cc9a2271 100644 --- a/src/java/nodeData.ts +++ b/src/java/nodeData.ts @@ -31,6 +31,8 @@ export interface INodeData { handlerIdentifier?: string; uri?: string; kind: NodeKind; + /** Candidate physical ancestors and source root, subject to client-side visibility filters. */ + buildOutputPath?: INodeData[]; children?: any[]; metaData?: { [id: string]: any }; } diff --git a/src/views/PrimaryTypeNode.ts b/src/views/PrimaryTypeNode.ts index 0d80bf8b..be82eb01 100644 --- a/src/views/PrimaryTypeNode.ts +++ b/src/views/PrimaryTypeNode.ts @@ -6,6 +6,7 @@ import { createUuid, sendOperationEnd, sendOperationStart } from "vscode-extensi import { Commands } from "../commands"; import { Explorer } from "../constants"; import { INodeData, TypeKind } from "../java/nodeData"; +import { PackageRootKind } from "../java/packageRootNodeData"; import { Settings } from "../settings"; import { isTest } from "../utility"; import { DataNode } from "./dataNode"; @@ -125,8 +126,9 @@ export class PrimaryTypeNode extends DataNode { contextValue += "+test"; } - if (this._rootNode?.getParent() instanceof ProjectNode - && (this._rootNode.getParent() as ProjectNode).nodeData?.metaData?.MaxSourceVersion >= 16) { + const rootData = this._rootNode?.nodeData; + if (rootData && "entryKind" in rootData && rootData.entryKind === PackageRootKind.K_SOURCE + && this.getProjectAncestor()?.nodeData.metaData?.MaxSourceVersion >= 16) { contextValue += "+allowRecord"; } @@ -138,14 +140,15 @@ export class PrimaryTypeNode extends DataNode { * otherwise undefined. */ private getUnmanagedFolderAncestor(): ProjectNode | undefined { + const project = this.getProjectAncestor(); + return project?.isUnmanagedFolder() ? project : undefined; + } + + private getProjectAncestor(): ProjectNode | undefined { let ancestor = this.getParent(); while (ancestor && !(ancestor instanceof ProjectNode)) { ancestor = ancestor.getParent(); } - if (ancestor?.isUnmanagedFolder()) { - return ancestor; - } - - return undefined; + return ancestor; } } diff --git a/test/e2e-plans/java-dep-generated-sources-exclusions.yaml b/test/e2e-plans/java-dep-generated-sources-exclusions.yaml new file mode 100644 index 00000000..9801db80 --- /dev/null +++ b/test/e2e-plans/java-dep-generated-sources-exclusions.yaml @@ -0,0 +1,194 @@ +# Test Plan: Java Dependency - Generated Sources with an Excluded Ancestor +# +# AutoTest has no settings-update action, so a separate isolated profile supplies +# files.exclude. Exclusion transitions and explicit source-root exclusions are +# covered by test/generated-sources-suite/projectView.test.ts. + +name: "Java Dependency - Generated Sources with an Excluded Ancestor" +description: | + Excluding the physical target directory must retain the generated Java source + root at project level with its original label. Switching back to GeneratedApp's + editor tab must reveal it through link-with-editor in both flat and hierarchical + package presentation. + +setup: + extension: "vscjava.vscode-java-pack" + vscodeVersion: "stable" + workspace: "../generated-sources" + timeout: 180 + settings: + java.configuration.checkProjectSettingsExclusions: false + java.dependency.packagePresentation: "flat" + java.dependency.syncWithFolderExplorer: true + java.project.explorer.showNonJavaResources: true + files.exclude: + "**/target": true + workbench.editor.enablePreview: false + workbench.startupEditor: "none" + +steps: + - id: "ls-ready" + action: "waitForLanguageServer" + timeout: 180 + + - id: "close-aux-bar" + action: "executeVSCodeCommand workbench.action.closeAuxiliaryBar" + + - id: "collapse-outline" + action: "collapseSidebarSection OUTLINE" + + - id: "collapse-timeline" + action: "collapseSidebarSection TIMELINE" + + - id: "collapse-workspace-pane" + action: "collapseSidebarSection generated-sources" + + - id: "focus-java-projects" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "generated-sources-tree" + exact: true + inView: "Java Projects" + timeout: 30 + + - id: "expand-project" + action: "expandTreeItem generated-sources-tree" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-excluded-target-hidden" + action: "wait 1 seconds" + verifyTreeItem: + name: "target" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + # Open from the Java tree: Quick Open's file search also honors files.exclude. + - id: "expand-logical-source-root" + action: "expandTreeItem target/generated-sources/demo" + verifyTreeItem: + name: "com.example.generated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "expand-generated-package" + action: "expandTreeItem com.example.generated" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "open-generated-type" + action: "doubleClick GeneratedApp tree item" + verifyEditorTab: + title: "GeneratedApp.java" + timeout: 15 + + # Keep GeneratedApp open, then activate pom.xml in the adjacent editor tab. + # Switching back does not search the excluded directory through Quick Open. + - id: "open-pom-before-flat-editor-link" + action: "open file pom.xml" + verifyEditorTab: + title: "pom.xml" + + - id: "collapse-workspace-before-flat-editor-link" + action: "collapseSidebarSection generated-sources" + + - id: "focus-java-projects-before-flat-editor-link" + action: "executeVSCodeCommand javaProjectExplorer.focus" + + - id: "collapse-before-flat-editor-link" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "GeneratedApp" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "activate-generated-tab-in-flat-view" + action: "executeVSCodeCommand workbench.action.previousEditor" + verifyEditorTab: + title: "GeneratedApp.java" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-flat-editor-link-retains-original-root-label" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "switch-to-hierarchical" + action: "executeVSCodeCommand java.view.package.changeToHierarchicalPackageView" + + - id: "open-pom-before-hierarchical-editor-link" + action: "open file pom.xml" + verifyEditorTab: + title: "pom.xml" + + - id: "collapse-workspace-before-hierarchical-editor-link" + action: "collapseSidebarSection generated-sources" + + - id: "focus-java-projects-before-hierarchical-editor-link" + action: "executeVSCodeCommand javaProjectExplorer.focus" + + - id: "collapse-before-hierarchical-editor-link" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "GeneratedApp" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "activate-generated-tab-in-hierarchical-view" + action: "executeVSCodeCommand workbench.action.previousEditor" + verifyEditorTab: + title: "GeneratedApp.java" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-hierarchical-editor-link-retains-original-root-label" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-hierarchical-target-stays-excluded" + action: "wait 1 seconds" + verifyTreeItem: + name: "target" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "verify-no-shortened-source-root" + action: "wait 1 seconds" + verifyTreeItem: + name: "demo" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 diff --git a/test/e2e-plans/java-dep-generated-sources-tree.yaml b/test/e2e-plans/java-dep-generated-sources-tree.yaml index fda01315..4c747975 100644 --- a/test/e2e-plans/java-dep-generated-sources-tree.yaml +++ b/test/e2e-plans/java-dep-generated-sources-tree.yaml @@ -3,6 +3,7 @@ # Verifies that a generated Java source root under Maven's physical target # directory is rendered as one merged hierarchy when non-Java resources are # shown, while Hide keeps the Java source root available at the project level. +# Link-with-editor must reveal both layouts after changing package presentation. # # Usage: # npx autotest run test/e2e-plans/java-dep-generated-sources-tree.yaml --vsix @@ -10,7 +11,8 @@ name: "Java Dependency — Generated Sources Tree" description: | Tests the merged Java Projects hierarchy for a physical Maven target folder - containing a generated Java source root and ordinary build output. + containing a generated Java source root and ordinary build output, including + link-with-editor after Show/Hide and flat/hierarchical presentation transitions. setup: extension: "vscjava.vscode-java-pack" @@ -21,6 +23,7 @@ setup: java.configuration.checkProjectSettingsExclusions: false java.dependency.packagePresentation: "flat" java.project.explorer.showNonJavaResources: true + files.exclude: {} workbench.startupEditor: "none" steps: @@ -46,7 +49,12 @@ steps: verify: "Java Projects view is focused" - id: "wait-tree-load" - action: "wait 3 seconds" + action: "wait 1 seconds" + verifyTreeItem: + name: "generated-sources-tree" + exact: true + inView: "Java Projects" + timeout: 30 - id: "expand-project" action: "expandTreeItem generated-sources-tree" @@ -74,6 +82,8 @@ steps: - id: "open-generated-file" action: "open file GeneratedApp.java" waitBefore: 2 + verifyEditorTab: + title: "GeneratedApp.java" - id: "collapse-workspace-root-after-generated-file" action: "collapseWorkspaceRoot" @@ -136,10 +146,10 @@ steps: timeout: 15 - id: "hide-non-java-resources" - action: 'clickViewTitleAction "Java Projects" "Hide Non-Java Resources"' + action: "executeVSCodeCommand java.project.explorer.hideNonJavaResources" - id: "wait-hide-refresh" - action: "wait 3 seconds" + action: "wait 1 seconds" - id: "expand-project-after-hide" action: "expandTreeItem generated-sources-tree" @@ -161,14 +171,58 @@ steps: inView: "Java Projects" timeout: 15 + - id: "collapse-before-hidden-editor-link" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "GeneratedApp" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "open-generated-file-after-hide" + action: "open file GeneratedApp.java" + verifyEditorTab: + title: "GeneratedApp.java" + + - id: "verify-linked-generated-file-after-hide" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-hidden-layout-keeps-original-root-label" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + - id: "show-non-java-resources" - action: 'clickViewTitleAction "Java Projects" "Show Non-Java Resources"' + action: "executeVSCodeCommand java.project.explorer.showNonJavaResources" - id: "wait-show-refresh" - action: "wait 5 seconds" + action: "wait 1 seconds" + + # Activate another editor before collapsing, so reopening GeneratedApp must + # trigger link-with-editor rather than leaving an already active tab unchanged. + - id: "open-pom-before-show-editor-link" + action: "open file pom.xml" + verifyEditorTab: + title: "pom.xml" - id: "reset-tree-after-show" action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "GeneratedApp" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 - id: "expand-project-after-show" action: "expandTreeItem generated-sources-tree" @@ -190,3 +244,58 @@ steps: visible: false inView: "Java Projects" timeout: 15 + + - id: "reopen-generated-file-after-show" + action: "open file GeneratedApp.java" + verifyEditorTab: + title: "GeneratedApp.java" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "switch-to-hierarchical" + action: "executeVSCodeCommand java.view.package.changeToHierarchicalPackageView" + + - id: "open-pom-before-hierarchical-editor-link" + action: "open file pom.xml" + verifyEditorTab: + title: "pom.xml" + + - id: "collapse-hierarchical-tree" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "GeneratedApp" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "reopen-generated-file-in-hierarchical-view" + action: "open file GeneratedApp.java" + verifyEditorTab: + title: "GeneratedApp.java" + verifyTreeItem: + name: "GeneratedApp" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-hierarchical-view-retains-merged-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-hierarchical-view-does-not-duplicate-root" + action: "wait 1 seconds" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 diff --git a/test/generated-sources-suite/projectView.test.ts b/test/generated-sources-suite/projectView.test.ts index 1752d4e8..ea717f8e 100644 --- a/test/generated-sources-suite/projectView.test.ts +++ b/test/generated-sources-suite/projectView.test.ts @@ -5,6 +5,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { Commands, + ContainerNode, contextManager, DataNode, DependencyExplorer, @@ -13,124 +14,252 @@ import { Jdtls, languageServerApiManager, NodeKind, + PackageNode, + PackageRootKind, PackageRootNode, PrimaryTypeNode, ProjectNode, } from "../../extension.bundle"; +import { ExplorerNode } from "../../src/views/explorerNode"; import { printNodes, setupTestEnv } from "../shared"; import { sleep } from "../util"; +type FileExcludes = { [pattern: string]: boolean | { when: string } }; + +const generatedRootPath = "target/generated-sources/demo"; + // tslint:disable: only-arrow-functions suite("Generated Source Tree Tests", () => { let originalShowNonJavaResources: boolean | undefined; + let originalFileExcludes: FileExcludes | undefined; + let originalPackagePresentation: string | undefined; suiteSetup(async () => { originalShowNonJavaResources = vscode.workspace.getConfiguration("java.project.explorer") .inspect("showNonJavaResources")?.workspaceValue; - await vscode.workspace.getConfiguration("java.project.explorer").update( - "showNonJavaResources", - true, - vscode.ConfigurationTarget.Workspace, - ); + originalFileExcludes = vscode.workspace.getConfiguration("files").inspect("exclude")?.workspaceValue; + originalPackagePresentation = vscode.workspace.getConfiguration("java.dependency") + .inspect("packagePresentation")?.workspaceValue; await setupTestEnv(); await languageServerApiManager.ready(); - await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); - await getProjectNode(); }); - suiteTeardown(async () => { + teardown(async () => { await vscode.workspace.getConfiguration("java.project.explorer").update( "showNonJavaResources", originalShowNonJavaResources, vscode.ConfigurationTarget.Workspace, ); - }); - - test("Merges generated source roots into the physical build output tree", async function() { - const projectNode = await getProjectNode(); - const projectChildren = await projectNode.getChildren(); - const targetFolder = projectChildren.find((node: DataNode) => - node instanceof FolderNode && node.name === "target") as FolderNode; - - assert.ok(targetFolder, `The physical target folder should be visible.\n${printNodes(projectChildren)}`); - assert.ok(!projectChildren.find((node: DataNode) => - node instanceof PackageRootNode && node.path?.endsWith("/target/generated-sources/demo")), - "The generated source root should not be duplicated at the project level"); - - const targetChildren = await targetFolder.getChildren(); - assert.ok(targetChildren.find((node: DataNode) => - node instanceof FileNode && node.name === "build-info.txt"), - "Non-Java build output should remain visible"); - - const generatedSourcesFolder = targetChildren.find((node: DataNode) => - node instanceof FolderNode && node.name === "generated-sources") as FolderNode; - assert.ok(generatedSourcesFolder, "The generated-sources ancestor should be visible under target"); - - const generatedSourcesChildren = await generatedSourcesFolder.getChildren(); - const demoRoot = generatedSourcesChildren.find((node: DataNode) => - node instanceof PackageRootNode && node.getDisplayName() === "demo") as PackageRootNode; - assert.ok(demoRoot, "The generated Java source root should be nested under its physical ancestors"); - assert.ok(demoRoot.path?.endsWith("/target/generated-sources/demo")); - }); - - test("Resolves Java files through the merged build output hierarchy", async function() { - const workspaceFolder = vscode.workspace.workspaceFolders![0]; - const generatedTypeUri = vscode.Uri.joinPath( - workspaceFolder.uri, - "target", - "generated-sources", - "demo", - "com", - "example", - "generated", - "GeneratedApp.java", + await vscode.workspace.getConfiguration("files").update( + "exclude", + originalFileExcludes, + vscode.ConfigurationTarget.Workspace, ); - const paths = await Jdtls.resolvePath(generatedTypeUri.toString()); - - assert.deepEqual(paths.slice(0, 4).map(path => path.kind), [ - NodeKind.Project, - NodeKind.Folder, - NodeKind.Folder, - NodeKind.PackageRoot, - ]); - assert.equal(paths[1].name, "target"); - assert.equal(paths[2].name, "generated-sources"); - assert.equal(paths[3].displayName, "demo"); - - const explorer = DependencyExplorer.getInstance(contextManager.context); - const revealedNode = await explorer.dataProvider.revealPaths(paths); - assert.ok(revealedNode instanceof PrimaryTypeNode, "The generated Java type should be revealable"); - assert.equal(revealedNode.name, "GeneratedApp"); - }); - - test("Keeps generated source roots visible when non-Java resources are hidden", async function() { - await vscode.workspace.getConfiguration("java.project.explorer").update( - "showNonJavaResources", - false, + await vscode.workspace.getConfiguration("java.dependency").update( + "packagePresentation", + originalPackagePresentation, vscode.ConfigurationTarget.Workspace, ); await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + }); + + test("Preserves record contexts for ordinary source roots and binary dependencies", async function() { + const project = await getProjectNode(); + const container = new ContainerNode({ kind: NodeKind.Container, name: "Dependencies" }, project, project); + for (const entryKind of [PackageRootKind.K_SOURCE, PackageRootKind.K_BINARY]) { + const allowsRecords = entryKind === PackageRootKind.K_SOURCE; + const rootData = { + kind: NodeKind.PackageRoot, + name: allowsRecords ? "src/main/java" : "dependency.jar", + entryKind, + attributes: new Map(), + }; + const root = new PackageRootNode(rootData, allowsRecords ? project : container, project); + const packageNode = new PackageNode({ kind: NodeKind.Package, name: "example" }, root, project, root); + const type = new PrimaryTypeNode({ kind: NodeKind.PrimaryType, name: "Example" }, packageNode, root); - try { - const projectNode = await getProjectNode(); - const projectChildren = await projectNode.getChildren(); - assert.ok(!projectChildren.find((node: DataNode) => - node instanceof FolderNode && node.name === "target"), - "The physical target folder should be hidden"); - assert.ok(projectChildren.find((node: DataNode) => - node instanceof PackageRootNode && node.path?.endsWith("/target/generated-sources/demo")), - "The Java source root should remain visible at the project level"); - } finally { - await vscode.workspace.getConfiguration("java.project.explorer").update( - "showNonJavaResources", - true, - vscode.ConfigurationTarget.Workspace, - ); - await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + assert.equal(root.computeContextValue()?.includes("+allowRecord"), allowsRecords, + `${rootData.name} should preserve its source/binary root context`); + assert.equal(type.computeContextValue()?.includes("+allowRecord"), allowsRecords, + `${rootData.name} should allow records only for source types, not binary dependencies`); } }); + + for (const presentation of ["flat", "hierarchical"]) { + suite(`${presentation} package presentation`, () => { + setup(async () => { + await vscode.workspace.getConfiguration("java.dependency").update( + "packagePresentation", + presentation, + vscode.ConfigurationTarget.Workspace, + ); + await setShowNonJavaResources(true); + await setFileExcludes({}); + await getProjectNode(); + }); + + test("Merges generated roots and preserves reveal and record context across Show/Hide", async function() { + await assertMergedLayout(); + await assertRevealedType(true); + + await setShowNonJavaResources(false); + const projectChildren = await (await getProjectNode()).getChildren(); + assert.ok(!projectChildren.some(node => node instanceof FolderNode && node.name === "target"), + "Hide should remove the physical target folder"); + assertLogicalRoot(projectChildren); + await assertRevealedType(false); + + await setShowNonJavaResources(true); + await assertMergedLayout(); + await assertRevealedType(true); + }); + + test("Falls back around excluded ancestors and merges again when exclusions are removed", async function() { + for (const pattern of ["**/target", "**/target/generated-sources"]) { + await setFileExcludes({ [pattern]: true }); + const projectChildren = await (await getProjectNode()).getChildren(); + assertLogicalRoot(projectChildren); + + if (pattern === "**/target") { + assert.ok(!projectChildren.some(node => node instanceof FolderNode && node.name === "target"), + "An excluded target folder must stay hidden"); + } else { + const targetChildren = await getFolder(projectChildren, "target").getChildren(); + assertBuildInfoVisible(targetChildren); + assert.ok(!targetChildren.some(node => node.getDisplayName() === "generated-sources"), + "An excluded intermediate ancestor must stay hidden"); + assert.equal(getGeneratedRoots(targetChildren).length, 0, + "The fallback root belongs at project level, not directly under target"); + } + + await assertRevealedType(false); + await setFileExcludes({}); + await assertMergedLayout(); + await assertRevealedType(true); + } + }); + + test("Does not bypass exclusions matching the generated source root itself", async function() { + for (const pattern of ["**/target/generated-sources/demo", "**/target/**"]) { + await setFileExcludes({ [pattern]: true }); + const projectChildren = await (await getProjectNode()).getChildren(); + assert.equal(getGeneratedRoots(projectChildren).length, 0, + `${pattern} must not expose a fallback source root at project level`); + + if (pattern === "**/target/**") { + const target = projectChildren.find((node): node is FolderNode => + node instanceof FolderNode && node.name === "target"); + if (target) { + const targetChildren = await target.getChildren(); + assert.equal(getGeneratedRoots(targetChildren).length, 0); + assert.ok(!targetChildren.some(node => node.getDisplayName() === "generated-sources"), + "The excluded generated-sources folder must not appear in the physical tree"); + assert.ok(!targetChildren.some(node => node.getDisplayName() === "build-info.txt"), + "The descendant exclusion must also hide ordinary build output"); + } + } else { + const targetChildren = await getFolder(projectChildren, "target").getChildren(); + assert.equal(getGeneratedRoots(targetChildren).length, 0, + "The excluded root must not appear directly under target"); + assertBuildInfoVisible(targetChildren); + const generatedSourcesChildren = await getFolder(targetChildren, "generated-sources").getChildren(); + assert.ok(!generatedSourcesChildren.some(node => node.getDisplayName() === "demo"), + "The explicitly excluded source root must not appear under its physical ancestors"); + assert.equal(getGeneratedRoots(generatedSourcesChildren).length, 0); + } + + const paths = await Jdtls.resolvePath(getGeneratedTypeUri().toString()); + const explorer = DependencyExplorer.getInstance(contextManager.context); + const revealedNode = await explorer.dataProvider.revealPaths(paths); + assert.ok(!(revealedNode instanceof PrimaryTypeNode), + `${pattern} must prevent revealing the excluded generated Java type`); + + await setFileExcludes({}); + await assertMergedLayout(); + await assertRevealedType(true); + } + }); + }); + } }); +async function setShowNonJavaResources(show: boolean): Promise { + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + show, + vscode.ConfigurationTarget.Workspace, + ); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); +} + +async function setFileExcludes(excludes: FileExcludes): Promise { + await vscode.workspace.getConfiguration("files").update("exclude", excludes, vscode.ConfigurationTarget.Workspace); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); +} + +function getGeneratedRoots(nodes: ExplorerNode[]): PackageRootNode[] { + return nodes.filter((node): node is PackageRootNode => + node instanceof PackageRootNode && !!node.path?.endsWith(`/${generatedRootPath}`)); +} + +function getFolder(nodes: ExplorerNode[], name: string): FolderNode { + const folder = nodes.find((node): node is FolderNode => node instanceof FolderNode && node.name === name); + assert.ok(folder, `The physical ${name} folder should be visible.\n${printNodes(nodes)}`); + return folder; +} + +function assertBuildInfoVisible(nodes: ExplorerNode[]): void { + assert.ok(nodes.some(node => node instanceof FileNode && node.name === "build-info.txt"), + `Non-Java build output should remain visible.\n${printNodes(nodes)}`); +} + +function assertLogicalRoot(nodes: ExplorerNode[]): void { + const roots = getGeneratedRoots(nodes); + assert.equal(roots.length, 1, `Exactly one generated source root should remain at project level.\n${printNodes(nodes)}`); + assert.equal(roots[0].getDisplayName(), generatedRootPath, "The fallback root should keep its original display name"); +} + +async function assertMergedLayout(): Promise { + const projectChildren = await (await getProjectNode()).getChildren(); + assert.equal(getGeneratedRoots(projectChildren).length, 0, + "The generated source root should not be duplicated at project level"); + const targetChildren = await getFolder(projectChildren, "target").getChildren(); + assertBuildInfoVisible(targetChildren); + const generatedSourcesChildren = await getFolder(targetChildren, "generated-sources").getChildren(); + const roots = getGeneratedRoots(generatedSourcesChildren); + assert.equal(roots.length, 1, "The generated source root should occur once under its physical ancestors"); + assert.equal(roots[0].getDisplayName(), "demo"); +} + +function getGeneratedTypeUri(): vscode.Uri { + return vscode.Uri.joinPath( + vscode.workspace.workspaceFolders![0].uri, + "target", "generated-sources", "demo", "com", "example", "generated", "GeneratedApp.java", + ); +} + +async function assertRevealedType(merged: boolean): Promise { + const paths = await Jdtls.resolvePath(getGeneratedTypeUri().toString()); + const ancestors = merged ? [NodeKind.Project, NodeKind.Folder, NodeKind.Folder] : [NodeKind.Project]; + assert.deepStrictEqual(paths.map(path => path.kind), [ + ...ancestors, NodeKind.PackageRoot, NodeKind.Package, NodeKind.PrimaryType, + ], "The resolved path must match the visible tree layout"); + if (merged) { + assert.equal(paths[1].name, "target"); + assert.equal(paths[2].name, "generated-sources"); + } + const root = paths[ancestors.length]; + assert.ok(root.path?.endsWith(`/${generatedRootPath}`)); + assert.equal(root.displayName || root.name, merged ? "demo" : generatedRootPath); + + const explorer = DependencyExplorer.getInstance(contextManager.context); + const revealedNode = await explorer.dataProvider.revealPaths(paths); + assert.ok(revealedNode instanceof PrimaryTypeNode, "The generated Java type should be revealable"); + assert.equal(revealedNode.name, "GeneratedApp"); + assert.ok(revealedNode.computeContextValue()?.includes("+allowRecord"), + "GeneratedApp in the Java 17 source root should allow creating records in either layout"); +} + async function getProjectNode(): Promise { const explorer = DependencyExplorer.getInstance(contextManager.context); const deadline = Date.now() + 60 * 1000; From 4d4152cf2f8d60522375877a869885369b0923f7 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 15:49:12 +0800 Subject: [PATCH 4/5] test: cover nested generated source roots --- .../java-dep-nested-generated-sources.yaml | 237 ++++++++++++++++++ test/nested-generated-sources/.classpath | 7 + test/nested-generated-sources/.project | 15 ++ .../target/build-info.txt | 1 + .../com/example/outer/OuterGenerated.java | 4 + .../com/example/nested/InnerGenerated.java | 4 + 6 files changed, 268 insertions(+) create mode 100644 test/e2e-plans/java-dep-nested-generated-sources.yaml create mode 100644 test/nested-generated-sources/.classpath create mode 100644 test/nested-generated-sources/.project create mode 100644 test/nested-generated-sources/target/build-info.txt create mode 100644 test/nested-generated-sources/target/generated-sources/com/example/outer/OuterGenerated.java create mode 100644 test/nested-generated-sources/target/generated-sources/demo/com/example/nested/InnerGenerated.java diff --git a/test/e2e-plans/java-dep-nested-generated-sources.yaml b/test/e2e-plans/java-dep-nested-generated-sources.yaml new file mode 100644 index 00000000..b7a56037 --- /dev/null +++ b/test/e2e-plans/java-dep-nested-generated-sources.yaml @@ -0,0 +1,237 @@ +name: "Java Dependency - Nested Generated Source Roots" +description: | + A generated source root nested inside another source root must remain a + project-level logical root rather than disappearing inside the merged tree. + Both roots must remain revealable across flat/hierarchical and Show/Hide modes. + +setup: + extension: "vscjava.vscode-java-pack" + vscodeVersion: "stable" + workspace: "../nested-generated-sources" + timeout: 180 + settings: + java.configuration.checkProjectSettingsExclusions: false + java.dependency.packagePresentation: "flat" + java.dependency.syncWithFolderExplorer: true + java.project.explorer.showNonJavaResources: true + files.exclude: {} + workbench.startupEditor: "none" + +steps: + - id: "ls-ready" + action: "waitForLanguageServer" + timeout: 180 + + - id: "close-aux-bar" + action: "executeVSCodeCommand workbench.action.closeAuxiliaryBar" + + - id: "collapse-outline" + action: "collapseSidebarSection OUTLINE" + + - id: "collapse-timeline" + action: "collapseSidebarSection TIMELINE" + + - id: "collapse-workspace-root" + action: "collapseWorkspaceRoot" + + - id: "focus-java-projects" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "nested-generated-sources" + exact: true + inView: "Java Projects" + timeout: 30 + + - id: "expand-project" + action: "expandTreeItem nested-generated-sources" + verifyTreeItem: + name: "target" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-inner-logical-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "open-inner-source" + action: "open file InnerGenerated.java" + verifyEditorTab: + title: "InnerGenerated.java" + + - id: "collapse-workspace-after-inner-source" + action: "collapseWorkspaceRoot" + + - id: "verify-flat-inner-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "InnerGenerated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-inner-not-merged" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "demo" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "open-outer-source" + action: "open file OuterGenerated.java" + verifyEditorTab: + title: "OuterGenerated.java" + + - id: "collapse-workspace-after-outer-source" + action: "collapseWorkspaceRoot" + + - id: "verify-flat-outer-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "OuterGenerated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-outer-merged-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "generated-sources" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-no-duplicate-outer-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources" + exact: true + visible: false + inView: "Java Projects" + timeout: 15 + + - id: "switch-to-hierarchical" + action: "executeVSCodeCommand java.view.package.changeToHierarchicalPackageView" + + - id: "collapse-before-hierarchical-reveal" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + verifyTreeItem: + name: "InnerGenerated" + exact: true + visible: false + inView: "Java Projects" + + - id: "reopen-inner-source" + action: "open file InnerGenerated.java" + verifyEditorTab: + title: "InnerGenerated.java" + + - id: "verify-hierarchical-inner-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "InnerGenerated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-hierarchical-inner-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + + - id: "reopen-outer-source" + action: "open file OuterGenerated.java" + verifyEditorTab: + title: "OuterGenerated.java" + + - id: "verify-hierarchical-outer-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "OuterGenerated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "hide-non-java-resources" + action: "executeVSCodeCommand java.project.explorer.hideNonJavaResources" + + - id: "collapse-hidden-tree" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + + - id: "expand-hidden-project" + action: "expandTreeItem nested-generated-sources" + verifyTreeItem: + name: "target/generated-sources" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 + + - id: "verify-hidden-inner-root" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + + - id: "verify-target-hidden" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target" + exact: true + visible: false + inView: "Java Projects" + + - id: "open-inner-source-while-hidden" + action: "open file InnerGenerated.java" + verifyEditorTab: + title: "InnerGenerated.java" + + - id: "verify-hidden-inner-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "InnerGenerated" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "show-non-java-resources" + action: "executeVSCodeCommand java.project.explorer.showNonJavaResources" + + - id: "open-build-info" + action: "open file build-info.txt" + verifyEditorTab: + title: "build-info.txt" + + - id: "collapse-workspace-after-build-info" + action: "collapseWorkspaceRoot" + + - id: "verify-build-info-reveal" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "build-info.txt" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-inner-root-after-show" + action: "executeVSCodeCommand javaProjectExplorer.focus" + verifyTreeItem: + name: "target/generated-sources/demo" + exact: true + count: 1 + inView: "Java Projects" + timeout: 15 diff --git a/test/nested-generated-sources/.classpath b/test/nested-generated-sources/.classpath new file mode 100644 index 00000000..2fa6b3da --- /dev/null +++ b/test/nested-generated-sources/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/nested-generated-sources/.project b/test/nested-generated-sources/.project new file mode 100644 index 00000000..22f0135d --- /dev/null +++ b/test/nested-generated-sources/.project @@ -0,0 +1,15 @@ + + + nested-generated-sources + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.jdt.core.javanature + + diff --git a/test/nested-generated-sources/target/build-info.txt b/test/nested-generated-sources/target/build-info.txt new file mode 100644 index 00000000..8ca83493 --- /dev/null +++ b/test/nested-generated-sources/target/build-info.txt @@ -0,0 +1 @@ +Non-Java build output alongside nested Java source roots. diff --git a/test/nested-generated-sources/target/generated-sources/com/example/outer/OuterGenerated.java b/test/nested-generated-sources/target/generated-sources/com/example/outer/OuterGenerated.java new file mode 100644 index 00000000..0c5b15fb --- /dev/null +++ b/test/nested-generated-sources/target/generated-sources/com/example/outer/OuterGenerated.java @@ -0,0 +1,4 @@ +package com.example.outer; + +public class OuterGenerated { +} diff --git a/test/nested-generated-sources/target/generated-sources/demo/com/example/nested/InnerGenerated.java b/test/nested-generated-sources/target/generated-sources/demo/com/example/nested/InnerGenerated.java new file mode 100644 index 00000000..76adbf23 --- /dev/null +++ b/test/nested-generated-sources/target/generated-sources/demo/com/example/nested/InnerGenerated.java @@ -0,0 +1,4 @@ +package com.example.nested; + +public class InnerGenerated { +} From 7dbe220e8ee329d793ac8188aa8a5ff74a4bcf92 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 16:17:59 +0800 Subject: [PATCH 5/5] fix: preserve source-root creation and nested-root visibility Query unmerged project data for Class and Package creation, and keep nested source roots at project level when their physical path crosses another source root. Cover both regressions and preserve existing exclusion and reveal behavior. --- .../jdtls/ext/core/PackageCommand.java | 11 + src/explorerCommands/new.ts | 25 +- src/java/jdtls.ts | 6 +- .../java-dep-generated-sources-tree.yaml | 91 ++++++- .../java-dep-nested-generated-sources.yaml | 8 +- .../projectView.test.ts | 10 + test/suite/newCommands.test.ts | 226 ++++++++++++++++++ 7 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 test/suite/newCommands.test.ts diff --git a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java index 19d4b394..3cdc2b69 100644 --- a/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java +++ b/jdtls.ext/com.microsoft.jdtls.ext.core/src/com/microsoft/jdtls/ext/core/PackageCommand.java @@ -391,6 +391,17 @@ private static List getBuildOutputSourceRootPath(IPackageFragmentRo return Collections.emptyList(); } + // A source root loads packages, not the folder path to another source root. + // Keep nested roots at project level so both browsing and reveal can reach them. + for (IClasspathEntry entry : packageRoot.getJavaProject().getRawClasspath()) { + IPath entryPath = entry.getPath(); + if (entry.getEntryKind() == IClasspathEntry.CPE_SOURCE + && currentPath.isPrefixOf(entryPath) && entryPath.isPrefixOf(rootPath) + && !entryPath.equals(rootPath)) { + return Collections.emptyList(); + } + } + List result = new ArrayList<>(); while (currentPath.segmentCount() < rootPath.segmentCount()) { IFolder folder = ResourcesPlugin.getWorkspace().getRoot().getFolder(currentPath); diff --git a/src/explorerCommands/new.ts b/src/explorerCommands/new.ts index 5a5cb7cc..d237c4ae 100644 --- a/src/explorerCommands/new.ts +++ b/src/explorerCommands/new.ts @@ -8,6 +8,7 @@ import { commands, Extension, extensions, languages, Position, QuickPickItem, Qu window, workspace, WorkspaceEdit, WorkspaceFolder } from "vscode"; import { Commands, PrimaryTypeNode } from "../../extension.bundle"; import { ExtensionName } from "../constants"; +import { Jdtls } from "../java/jdtls"; import { NodeKind } from "../java/nodeData"; import { DataNode } from "../views/dataNode"; import { resourceRoots } from "../views/packageRootNode"; @@ -455,14 +456,18 @@ function isPrefix(parentPath: string, filePath: string): boolean { async function getPackageFsPath(node: DataNode): Promise { if (node.nodeData.kind === NodeKind.Project) { - const childrenNodes: DataNode[] = await node.getChildren() as DataNode[]; - const packageRoots: any[] = childrenNodes.filter((child) => { - return child.nodeData.kind === NodeKind.PackageRoot && !resourceRoots.includes(child.name); + const packageData = await Jdtls.getPackageData({ + kind: NodeKind.Project, + projectUri: node.uri, + mergeBuildOutputSourceRoots: false, }); + const packageRoots = packageData.filter((child) => { + return child.kind === NodeKind.PackageRoot && !resourceRoots.includes(child.name); + }).sort((a, b) => a.name < b.name ? -1 : 1); if (packageRoots.length < 1) { // This might happen for an invisible project with "_" as its root - const packageNode: DataNode | undefined = childrenNodes.find((child) => { - return child.nodeData.kind === NodeKind.Package; + const packageNode = packageData.find((child) => { + return child.kind === NodeKind.Package; }); if (!packageNode && node.uri) { // This means the .java files are in the default package. @@ -472,12 +477,12 @@ async function getPackageFsPath(node: DataNode): Promise { } return ""; } else if (packageRoots.length === 1) { - return Uri.parse(packageRoots[0].uri).fsPath; + return Uri.parse(packageRoots[0].uri!).fsPath; } else { const options: ISourceRootPickItem[] = packageRoots.map((root) => { return { label: root.name, - fsPath: Uri.parse(root.uri).fsPath, + fsPath: Uri.parse(root.uri!).fsPath, }; }); const choice: ISourceRootPickItem | undefined = await window.showQuickPick(options, { @@ -585,8 +590,12 @@ async function getPackageInformationFromUri(uri: Uri): Promise | undefined> { const nodeKind = node.nodeData.kind; if (nodeKind === NodeKind.Project) { + const packageRootPath = await getPackageFsPath(node); + if (packageRootPath === undefined) { + return undefined; + } return { - packageRootPath: await getPackageFsPath(node) || "", + packageRootPath, defaultValue: "", }; } else if (nodeKind === NodeKind.PackageRoot) { diff --git a/src/java/jdtls.ts b/src/java/jdtls.ts index a06176ff..bce3cce9 100644 --- a/src/java/jdtls.ts +++ b/src/java/jdtls.ts @@ -31,13 +31,13 @@ export namespace Jdtls { export async function getPackageData(params: IPackageDataParam): Promise { const nonJavaResourcesFiltered: boolean = Settings.nonJavaResourcesFiltered(); const isVisible = createNodeVisibilityFilter(params.projectUri, nonJavaResourcesFiltered); - params.mergeBuildOutputSourceRoots = !nonJavaResourcesFiltered; + params.mergeBuildOutputSourceRoots ??= !nonJavaResourcesFiltered; const nodeData: INodeData[] = await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_GETPACKAGEDATA, params) || []; return nodeData.filter(node => isVisible(node) - && (params.kind !== NodeKind.Project || !getVisibleBuildOutputPath(node, isVisible))); + && (!params.mergeBuildOutputSourceRoots || params.kind !== NodeKind.Project || !getVisibleBuildOutputPath(node, isVisible))); } export async function resolvePath(params: string): Promise { @@ -106,6 +106,8 @@ function getVisibleBuildOutputPath(node: INodeData, isVisible: (node: INodeData) interface IPackageDataParam { projectUri: string | undefined; + /** Set false to request logical source roots rather than their merged explorer layout. */ + mergeBuildOutputSourceRoots?: boolean; [key: string]: any; } diff --git a/test/e2e-plans/java-dep-generated-sources-tree.yaml b/test/e2e-plans/java-dep-generated-sources-tree.yaml index 4c747975..3fa399da 100644 --- a/test/e2e-plans/java-dep-generated-sources-tree.yaml +++ b/test/e2e-plans/java-dep-generated-sources-tree.yaml @@ -12,7 +12,8 @@ name: "Java Dependency — Generated Sources Tree" description: | Tests the merged Java Projects hierarchy for a physical Maven target folder containing a generated Java source root and ordinary build output, including - link-with-editor after Show/Hide and flat/hierarchical presentation transitions. + link-with-editor after Show/Hide and flat/hierarchical presentation transitions, + and Class/Package creation from the project node while its source root is merged. setup: extension: "vscjava.vscode-java-pack" @@ -299,3 +300,91 @@ steps: visible: false inView: "Java Projects" timeout: 15 + + # Show is still enabled. Use the project node's New... action, not a source + # folder or URI, so creation must find the logical root hidden under target. + - id: "save-before-project-class" + action: "executeVSCodeCommand workbench.action.files.saveAll" + + - id: "close-editors-before-project-class" + action: "executeVSCodeCommand workbench.action.closeAllEditors" + + - id: "collapse-workspace-before-project-class" + action: "collapseWorkspaceRoot" + + - id: "collapse-tree-before-project-class" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + + - id: "focus-tree-before-project-class" + action: "executeVSCodeCommand javaProjectExplorer.focus" + + - id: "select-project-for-class" + action: "click generated-sources-tree tree item" + + - id: "new-class-from-project" + action: "clickTreeItemAction generated-sources-tree New..." + + - id: "select-project-class-type" + action: "select Class option" + + # This fixture has only one source root, so no source-folder picker is shown. + - id: "enter-project-class-name" + action: "fillQuickInput ProjectCreatedClass" + + - id: "verify-project-class-in-generated-root" + action: "wait 2 seconds" + verifyFile: + path: "~/target/generated-sources/demo/ProjectCreatedClass.java" + exists: true + contains: "public class ProjectCreatedClass" + verifyEditorTab: + title: "ProjectCreatedClass.java" + timeout: 20 + + - id: "verify-no-class-at-project-root" + action: "wait 1 seconds" + verifyFile: + path: "~/ProjectCreatedClass.java" + exists: false + + - id: "save-before-project-package" + action: "executeVSCodeCommand workbench.action.files.saveAll" + + - id: "close-editors-before-project-package" + action: "executeVSCodeCommand workbench.action.closeAllEditors" + + - id: "collapse-workspace-before-project-package" + action: "collapseWorkspaceRoot" + + - id: "collapse-tree-before-project-package" + action: "executeVSCodeCommand workbench.actions.treeView.javaProjectExplorer.collapseAll" + + - id: "focus-tree-before-project-package" + action: "executeVSCodeCommand javaProjectExplorer.focus" + + - id: "select-project-for-package" + action: "click generated-sources-tree tree item" + + - id: "new-package-from-project" + action: "clickTreeItemAction generated-sources-tree New..." + + - id: "select-project-package-type" + action: "select Package option" + + - id: "enter-project-package-name" + action: "fillQuickInput com.example.projectcreated" + + # verifyFile's existence check also supports directories; an empty package + # must exist on disk even before the Java Projects view refreshes. + - id: "verify-project-package-in-generated-root" + action: "wait 2 seconds" + verifyFile: + path: "~/target/generated-sources/demo/com/example/projectcreated" + exists: true + timeout: 20 + + - id: "verify-no-package-at-project-root" + action: "wait 1 seconds" + verifyFile: + path: "~/com/example/projectcreated" + exists: false diff --git a/test/e2e-plans/java-dep-nested-generated-sources.yaml b/test/e2e-plans/java-dep-nested-generated-sources.yaml index b7a56037..3c92320c 100644 --- a/test/e2e-plans/java-dep-nested-generated-sources.yaml +++ b/test/e2e-plans/java-dep-nested-generated-sources.yaml @@ -32,7 +32,7 @@ steps: action: "collapseSidebarSection TIMELINE" - id: "collapse-workspace-root" - action: "collapseWorkspaceRoot" + action: "collapseSidebarSection nested-generated-sources" - id: "focus-java-projects" action: "executeVSCodeCommand javaProjectExplorer.focus" @@ -66,7 +66,7 @@ steps: title: "InnerGenerated.java" - id: "collapse-workspace-after-inner-source" - action: "collapseWorkspaceRoot" + action: "collapseSidebarSection nested-generated-sources" - id: "verify-flat-inner-reveal" action: "executeVSCodeCommand javaProjectExplorer.focus" @@ -91,7 +91,7 @@ steps: title: "OuterGenerated.java" - id: "collapse-workspace-after-outer-source" - action: "collapseWorkspaceRoot" + action: "collapseSidebarSection nested-generated-sources" - id: "verify-flat-outer-reveal" action: "executeVSCodeCommand javaProjectExplorer.focus" @@ -217,7 +217,7 @@ steps: title: "build-info.txt" - id: "collapse-workspace-after-build-info" - action: "collapseWorkspaceRoot" + action: "collapseSidebarSection nested-generated-sources" - id: "verify-build-info-reveal" action: "executeVSCodeCommand javaProjectExplorer.focus" diff --git a/test/generated-sources-suite/projectView.test.ts b/test/generated-sources-suite/projectView.test.ts index ea717f8e..e405ec07 100644 --- a/test/generated-sources-suite/projectView.test.ts +++ b/test/generated-sources-suite/projectView.test.ts @@ -102,6 +102,16 @@ suite("Generated Source Tree Tests", () => { await assertMergedLayout(); await assertRevealedType(true); + const logicalChildren = await Jdtls.getPackageData({ + kind: NodeKind.Project, + projectUri: (await getProjectNode()).uri, + mergeBuildOutputSourceRoots: false, + }); + const logicalRoot = logicalChildren.find(node => + node.kind === NodeKind.PackageRoot && node.path?.endsWith(`/${generatedRootPath}`)); + assert.equal(logicalRoot?.name, generatedRootPath, + "Project actions must retain the logical source root even when its tree node is merged"); + await setShowNonJavaResources(false); const projectChildren = await (await getProjectNode()).getChildren(); assert.ok(!projectChildren.some(node => node instanceof FolderNode && node.name === "target"), diff --git a/test/suite/newCommands.test.ts b/test/suite/newCommands.test.ts new file mode 100644 index 00000000..8ad340d8 --- /dev/null +++ b/test/suite/newCommands.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as path from "path"; +import { commands, InputBoxOptions, QuickPickItem, Uri, window, workspace, WorkspaceEdit } from "vscode"; +import { Commands, INodeData, Jdtls, NodeKind, ProjectNode } from "../../extension.bundle"; +import { setupTestEnv } from "../shared"; + +interface ISourceRootPickItem extends QuickPickItem { + fsPath: string; +} + +suite("Project Creation Source Root Tests", () => { + const originalExecuteCommand = commands.executeCommand; + const originalGetConfiguration = workspace.getConfiguration; + const originalShowQuickPick = window.showQuickPick; + const originalShowInputBox = window.showInputBox; + const originalApplyEdit = workspace.applyEdit; + const originalShowTextDocument = window.showTextDocument; + const projectPath = path.resolve(__dirname, "new-command-project"); + const projectUri = Uri.file(projectPath).toString(); + let project: ProjectNode; + let packageData: INodeData[]; + let packageQueries: object[]; + let rootChoices: ISourceRootPickItem[][]; + let selectedRoot: string | undefined; + let inputValue: string | undefined; + let inputPrompts: InputBoxOptions[]; + let appliedEdits: WorkspaceEdit[]; + let childReads: number; + let excludes: { [pattern: string]: boolean }; + + suiteSetup(setupTestEnv); + + setup(() => { + packageData = []; + packageQueries = []; + rootChoices = []; + selectedRoot = undefined; + inputValue = undefined; + inputPrompts = []; + appliedEdits = []; + childReads = 0; + excludes = {}; + project = new ProjectNode({ kind: NodeKind.Project, name: "new-command-project", uri: projectUri }); + project.getChildren = async () => { + childReads++; + return []; + }; + commands.executeCommand = async (command: string, ...args: any[]): Promise => { + if (command === Commands.EXECUTE_WORKSPACE_COMMAND && args[0] === Commands.JAVA_GETPACKAGEDATA + && args[1]?.projectUri === projectUri) { + packageQueries.push({ ...args[1] }); + return packageData as unknown as T; + } + if (command === Commands.EXECUTE_WORKSPACE_COMMAND && args[0] === Commands.LIST_SOURCEPATHS) { + return { status: true, data: [] } as unknown as T; + } + return originalExecuteCommand(command, ...args); + }; + workspace.getConfiguration = (section, scope) => { + const configuration = originalGetConfiguration(section, scope); + if (section !== "java.project.explorer" && !(section === "files" && scope instanceof Uri && scope.toString() === projectUri)) { + return configuration; + } + return { + ...configuration, + get: ((key: string, defaultValue?: unknown) => { + if (section === "java.project.explorer" && key === "showNonJavaResources") { + return true; + } + if (section === "files" && key === "exclude") { + return excludes; + } + return configuration.get(key, defaultValue); + }) as typeof configuration.get, + }; + }; + window.showQuickPick = (async (items: readonly (string | QuickPickItem)[] | Thenable) => { + const roots = (await items).filter((item): item is ISourceRootPickItem => + typeof item !== "string" && "fsPath" in item && typeof item.fsPath === "string"); + rootChoices.push(roots); + return roots.find(item => item.label === selectedRoot); + }) as typeof window.showQuickPick; + window.showInputBox = async options => { + inputPrompts.push(options!); + return inputValue; + }; + // Capture the edit without creating files or opening an editor. + workspace.applyEdit = async edit => { + appliedEdits.push(edit); + return true; + }; + window.showTextDocument = async () => undefined!; + }); + + teardown(() => { + commands.executeCommand = originalExecuteCommand; + workspace.getConfiguration = originalGetConfiguration; + window.showQuickPick = originalShowQuickPick; + window.showInputBox = originalShowInputBox; + workspace.applyEdit = originalApplyEdit; + window.showTextDocument = originalShowTextDocument; + }); + + test("Class creation queries logical roots for the selected project instead of tree children", async () => { + const generated = sourceRoot("target/generated-sources/demo"); + generated.buildOutputPath = [ + { kind: NodeKind.Folder, name: "target", uri: Uri.file(path.join(projectPath, "target")).toString() }, + { ...generated, displayName: "demo" }, + ]; + packageData = [generated.buildOutputPath[0], generated, sourceRoot("src/main/resources")]; + + await createClass(); + + assert.deepStrictEqual(packageQueries, [{ kind: NodeKind.Project, projectUri, mergeBuildOutputSourceRoots: false }]); + assert.strictEqual(childReads, 0, "Source-root discovery must not depend on the visible tree"); + assert.strictEqual(rootChoices.length, 0, "A single Java source root must not show a picker"); + assertCreatedUnder(Uri.parse(generated.uri!).fsPath); + }); + + test("Multiple roots retain logical labels, resource filtering, exclusion filtering, and linked roots", async () => { + const main = sourceRoot("src/main/java"); + const generated = sourceRoot("target/generated-sources/demo"); + generated.displayName = "demo"; + const linked = sourceRoot("linked-sources", path.resolve(projectPath, "..", "linked-sources")); + const excluded = sourceRoot("excluded/java"); + excludes = { "**/excluded/**": true }; + packageData = [ + generated, excluded, sourceRoot("src/test/resources"), main, linked, sourceRoot("src/main/resources"), + ]; + selectedRoot = linked.name; + + await createClass(); + + assert.deepStrictEqual(rootChoices, [[linked, main, generated].map(root => ({ + label: root.name, + fsPath: Uri.parse(root.uri!).fsPath, + }))]); + assertCreatedUnder(Uri.parse(linked.uri!).fsPath); + assert.strictEqual(childReads, 0); + }); + + for (const command of [Commands.VIEW_PACKAGE_NEW_JAVA_CLASS, Commands.VIEW_PACKAGE_NEW_JAVA_PACKAGE]) { + test(`Cancelling source-root selection aborts ${command}`, async () => { + packageData = [sourceRoot("src/main/java"), sourceRoot("target/generated-sources/demo")]; + + await runCreationCommand(command); + + assert.strictEqual(rootChoices.length, 1); + assert.strictEqual(inputPrompts.length, 0, "Cancellation must not advance to the name prompt"); + assert.strictEqual(appliedEdits.length, 0); + }); + } + + test("Unmanaged projects derive their root from a logical qualified package", async () => { + const sourcePath = path.join(projectPath, "unmanaged-source"); + packageData = [{ + kind: NodeKind.Package, + name: "com.example", + displayName: "example", + uri: Uri.file(path.join(sourcePath, "com", "example")).toString(), + }]; + + await createClass(); + + assertCreatedUnder(sourcePath); + assert.strictEqual(rootChoices.length, 0); + }); + + test("Projects with only the default package retain the project-directory fallback", async () => { + packageData = [{ + kind: NodeKind.PrimaryType, + name: "Existing", + uri: Uri.file(path.join(projectPath, "Existing.java")).toString(), + }]; + + await createClass(); + + assertCreatedUnder(projectPath); + assert.strictEqual(rootChoices.length, 0); + }); + + test("Default display queries still merge roots while explicit logical queries retain them", async () => { + const target: INodeData = { + kind: NodeKind.Folder, + name: "target", + uri: Uri.file(path.join(projectPath, "target")).toString(), + }; + const generated = sourceRoot("target/generated-sources/demo"); + generated.buildOutputPath = [target, { ...generated, displayName: "demo" }]; + packageData = [target, generated]; + + assert.deepStrictEqual(await Jdtls.getPackageData({ kind: NodeKind.Project, projectUri }), [target]); + assert.deepStrictEqual(await Jdtls.getPackageData({ + kind: NodeKind.Project, + projectUri, + mergeBuildOutputSourceRoots: false, + }), [target, generated]); + assert.deepStrictEqual(packageQueries, [ + { kind: NodeKind.Project, projectUri, mergeBuildOutputSourceRoots: true }, + { kind: NodeKind.Project, projectUri, mergeBuildOutputSourceRoots: false }, + ]); + }); + + function sourceRoot(name: string, fsPath = path.join(projectPath, ...name.split("/"))): INodeData { + return { kind: NodeKind.PackageRoot, name, uri: Uri.file(fsPath).toString() }; + } + + async function createClass(): Promise { + inputValue = "NewType"; + await runCreationCommand(Commands.VIEW_PACKAGE_NEW_JAVA_CLASS); + } + + async function runCreationCommand(command: string): Promise { + await commands.executeCommand(command, project); + // The registered creation commands do not await their async helpers. + await new Promise(resolve => setImmediate(resolve)); + } + + function assertCreatedUnder(root: string): void { + assert.strictEqual(appliedEdits.length, 1); + assert.deepStrictEqual(appliedEdits[0].entries().map(([uri]) => uri.fsPath), [path.join(root, "NewType.java")]); + } +});