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..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 @@ -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()) { + addBuildOutputSourceRootPaths(result, javaProject); + } // Invisible project will always have the referenced libraries entry if (!ProjectUtils.isVisibleProject(project) || hasReferencedLibraries) { @@ -311,6 +307,162 @@ private static List getProjectChildren(PackageParams query, IProgre return result; } + private static void addBuildOutputSourceRootPaths(List nodes, IJavaProject javaProject) { + if (javaProject == null) { + return; + } + + try { + IFolder buildOutputRoot = getVisibleBuildOutputRoot(javaProject); + if (buildOutputRoot == null) { + return; + } + + // 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 resolve generated source root display paths", 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)) { + for (PackageNode childNode : getBuildOutputSourceRootPath(sourceRoot, buildOutputRoot)) { + IPath childPath = Path.fromPortableString(childNode.getPath()); + if (folderPath.equals(childPath.removeLastSegments(1)) && existingPaths.add(childPath)) { + nodes.add(childNode); + } + } + } + } + + 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; + } + + 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(); + } + + // 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); + 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; + } + + 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 +599,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 +619,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 +630,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 +641,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..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,9 @@ public class PackageParams { private boolean isHierarchicalView; + // Request candidate display paths; the client decides whether their ancestors are visible. + 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 +55,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/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/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 84a372a0..bce3cce9 100644 --- a/src/java/jdtls.ts +++ b/src/java/jdtls.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. -import * as minimatch from "minimatch"; +import { minimatch } from "minimatch"; import { CancellationToken, Uri, commands, workspace } from "vscode"; import { Commands, executeJavaLanguageServerCommand } from "../commands"; import { IClasspath } from "../tasks/buildArtifact/IStepMetadata"; @@ -29,43 +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 (Settings.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.mergeBuildOutputSourceRoots || params.kind !== NodeKind.Project || !getVisibleBuildOutputPath(node, isVisible))); } export async function resolvePath(params: string): Promise { - return await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_RESOLVEPATH, params) || []; + const nonJavaResourcesFiltered = Settings.nonJavaResourcesFiltered(); + const nodes: INodeData[] = await commands.executeCommand( + Commands.EXECUTE_WORKSPACE_COMMAND, + Commands.JAVA_RESOLVEPATH, + params, + !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 { @@ -98,8 +84,30 @@ 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; + /** Set false to request logical source roots rather than their merged explorer layout. */ + mergeBuildOutputSourceRoots?: boolean; [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 new file mode 100644 index 00000000..3fa399da --- /dev/null +++ b/test/e2e-plans/java-dep-generated-sources-tree.yaml @@ -0,0 +1,390 @@ +# 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. +# 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 + +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, + and Class/Package creation from the project node while its source root is merged. + +setup: + extension: "vscjava.vscode-java-pack" + vscodeVersion: "stable" + workspace: "../generated-sources" + timeout: 180 + settings: + java.configuration.checkProjectSettingsExclusions: false + java.dependency.packagePresentation: "flat" + 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" + 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 1 seconds" + verifyTreeItem: + name: "generated-sources-tree" + exact: true + inView: "Java Projects" + timeout: 30 + + - 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 + + # 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 + verifyEditorTab: + title: "GeneratedApp.java" + + - 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: "generated-sources" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-nested-source-root" + action: "wait 1 seconds" + verifyTreeItem: + name: "demo" + exact: true + inView: "Java Projects" + timeout: 15 + + - id: "verify-generated-package" + action: "wait 1 seconds" + verifyTreeItem: + name: "com.example.generated" + exact: true + 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: "executeVSCodeCommand java.project.explorer.hideNonJavaResources" + + - id: "wait-hide-refresh" + action: "wait 1 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: "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: "executeVSCodeCommand java.project.explorer.showNonJavaResources" + + - id: "wait-show-refresh" + 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" + + - 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 + + - 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 + + # 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 new file mode 100644 index 00000000..3c92320c --- /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: "collapseSidebarSection nested-generated-sources" + + - 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: "collapseSidebarSection nested-generated-sources" + + - 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: "collapseSidebarSection nested-generated-sources" + + - 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: "collapseSidebarSection nested-generated-sources" + + - 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/generated-sources-suite/index.ts b/test/generated-sources-suite/index.ts new file mode 100644 index 00000000..0ade574c --- /dev/null +++ b/test/generated-sources-suite/index.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { 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 }).then((files) => { + files.sort().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); + } + }).catch(e); + }); +} diff --git a/test/generated-sources-suite/projectView.test.ts b/test/generated-sources-suite/projectView.test.ts new file mode 100644 index 00000000..e405ec07 --- /dev/null +++ b/test/generated-sources-suite/projectView.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as vscode from "vscode"; +import { + Commands, + ContainerNode, + contextManager, + DataNode, + DependencyExplorer, + FileNode, + FolderNode, + 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; + originalFileExcludes = vscode.workspace.getConfiguration("files").inspect("exclude")?.workspaceValue; + originalPackagePresentation = vscode.workspace.getConfiguration("java.dependency") + .inspect("packagePresentation")?.workspaceValue; + await setupTestEnv(); + await languageServerApiManager.ready(); + }); + + teardown(async () => { + await vscode.workspace.getConfiguration("java.project.explorer").update( + "showNonJavaResources", + originalShowNonJavaResources, + vscode.ConfigurationTarget.Workspace, + ); + await vscode.workspace.getConfiguration("files").update( + "exclude", + originalFileExcludes, + vscode.ConfigurationTarget.Workspace, + ); + 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); + + 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); + + 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"), + "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; + 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, 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 { +} 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")]); + } +});