diff --git a/README.md b/README.md index 58de3e35..fa7104ad 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ You can turn these reminders on or off at any time with the `java.dependency.ena | `java.dependency.packagePresentation` | Specify how to display the package. Supported values are: `flat`, `hierarchical`.| `flat` | | `java.dependency.enableDependencyCheckup` | Show reminders when your Java runtimes or dependencies need an upgrade. | `true` | | `java.project.exportJar.targetPath` | The output path of export jar. When this setting is **empty** , a file explorer will pop up to let the user select the output location.| `${workspaceFolder}/${workspaceFolderBasename}.jar` | -| `java.project.explorer.showNonJavaResources` | When enabled, the explorer shows non-Java resources. When disabled, workspace folders without Java projects are hidden. | `true` | +| `java.project.explorer.showNonJavaResources` | When enabled, the explorer shows non-Java resources and lets you browse workspace folders without Java projects. When disabled, workspace folders without Java projects are hidden. | `true` | ## Contribution diff --git a/package.json b/package.json index 9206965c..9c75a74d 100644 --- a/package.json +++ b/package.json @@ -835,12 +835,12 @@ }, { "submenu": "javaProject.new", - "when": "view == javaProjectExplorer && viewItem =~ /java(?!:container)(?!:jar)(?!.*?\\b\\+binary\\b)(?=.*?\\b\\+uri\\b)/", + "when": "view == javaProjectExplorer && viewItem =~ /java(?!:container)(?!:jar)(?!:workspaceResource)(?!.*?\\b\\+binary\\b)(?=.*?\\b\\+uri\\b)/", "group": "1_new@10" }, { "command": "java.view.package.new", - "when": "view == javaProjectExplorer && viewItem =~ /java(?!:container)(?!:jar)(?!.*?\\b\\+binary\\b)(?=.*?\\b\\+uri\\b)/", + "when": "view == javaProjectExplorer && viewItem =~ /java(?!:container)(?!:jar)(?!:workspaceResource)(?!.*?\\b\\+binary\\b)(?=.*?\\b\\+uri\\b)/", "group": "inline@add_0" }, { diff --git a/package.nls.json b/package.nls.json index 2bfee6a2..e1c4a3d5 100644 --- a/package.nls.json +++ b/package.nls.json @@ -48,7 +48,7 @@ "configuration.java.dependency.refreshDelay": "The delay time (ms) the auto refresh is invoked when changes are detected", "configuration.java.dependency.packagePresentation": "Package presentation mode: flat or hierarchical", "configuration.java.dependency.enableDependencyCheckup": "Show reminders when your Java runtimes or dependencies need an upgrade.", - "configuration.java.project.explorer.showNonJavaResources": "When enabled, the explorer shows non-Java resources. When disabled, workspace folders without Java projects are hidden.", + "configuration.java.project.explorer.showNonJavaResources": "When enabled, the explorer shows non-Java resources and lets you browse workspace folders without Java projects. When disabled, workspace folders without Java projects are hidden.", "configuration.java.project.exportJar.targetPath.customization": "The output path of the exported jar. Leave it empty if you want to manually pick the output location.", "configuration.java.project.exportJar.targetPath.workspaceFolder": "Export the jar file into the workspace folder. Its name is the same as the folder's.", "configuration.java.project.exportJar.targetPath.select": "Select output location manually when exporting the jar file.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 29514fdf..179c02f5 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -44,7 +44,7 @@ "configuration.java.dependency.autoRefresh": "在 Java 项目管理器中自动同步修改", "configuration.java.dependency.refreshDelay": "控制 Java 项目管理器刷新的延迟时间 (毫秒)", "configuration.java.dependency.packagePresentation": "Java 包显示方式: 平行显示或者分层显示", - "configuration.java.project.explorer.showNonJavaResources": "启用时,Java 项目管理器将显示非 Java 资源;禁用时,将隐藏不包含 Java 项目的工作区文件夹。", + "configuration.java.project.explorer.showNonJavaResources": "启用时,Java 项目管理器将显示非 Java 资源,并可浏览不包含 Java 项目的工作区文件夹;禁用时,将隐藏不包含 Java 项目的工作区文件夹。", "configuration.java.project.exportJar.targetPath.customization": "导出 Jar 文件的路径。您可以将此选项置为空串来手动选择 jar 文件的导出路径。", "configuration.java.project.exportJar.targetPath.workspaceFolder": "导出 Jar 文件到工作空间文件夹下。Jar 文件的名称和工作空间文件夹的名称相同。", "configuration.java.project.exportJar.targetPath.select": "在导出 Jar 文件时手动选择输出目录。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 496e486b..85fdbd2c 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -37,7 +37,7 @@ "configuration.java.dependency.autoRefresh": "在 Java 專案管理員中自動同步修改", "configuration.java.dependency.refreshDelay": "控制 Java 專案管理員重新整理的延遲時間 (毫秒)", "configuration.java.dependency.packagePresentation": "Java 套件顯示方式: 平行顯示或者分層顯示", - "configuration.java.project.explorer.showNonJavaResources": "啟用時,Java 專案管理員會顯示非 Java 資源;停用時,將隱藏不包含 Java 專案的工作區資料夾。", + "configuration.java.project.explorer.showNonJavaResources": "啟用時,Java 專案管理員會顯示非 Java 資源,並可瀏覽不包含 Java 專案的工作區資料夾;停用時,將隱藏不包含 Java 專案的工作區資料夾。", "configuration.java.project.exportJar.targetPath.customization": "匯出 Jar 檔案的路徑。如果您想要手動選擇輸出路徑,可以不填。", "configuration.java.project.exportJar.targetPath.workspaceFolder": "匯出 Jar 檔案到工作區資料夾下。Jar 檔案的名稱和工作區資料夾的名稱相同。", "configuration.java.project.exportJar.targetPath.select": "在匯出 Jar 檔案時手動選擇輸出位置。", diff --git a/src/constants.ts b/src/constants.ts index 31150834..277e34c8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -22,6 +22,8 @@ export namespace Explorer { File = "file", Type = "type", Folder = "folder", + WorkspaceResourceFile = "workspaceResourceFile", + WorkspaceResourceFolder = "workspaceResourceFolder", Symbol = "symbol", } diff --git a/src/views/DragAndDropController.ts b/src/views/DragAndDropController.ts index 3ef2c0bf..78c383b6 100644 --- a/src/views/DragAndDropController.ts +++ b/src/views/DragAndDropController.ts @@ -16,6 +16,7 @@ import { PackageRootNode } from "./packageRootNode"; import { PrimaryTypeNode } from "./PrimaryTypeNode"; import { ProjectNode } from "./projectNode"; import { WorkspaceNode } from "./workspaceNode"; +import { isWorkspaceResourceNode } from "./workspaceResourceFolderNode"; import { addLibraryGlobs } from "../controllers/libraryController"; import { sendError, sendInfo } from "vscode-extension-telemetry-wrapper"; import { DocumentSymbolNode } from "./documentSymbolNode"; @@ -241,7 +242,7 @@ export class DragAndDropController implements TreeDragAndDropController { return project?.revealPaths(paths); } - public async getRootProjects(): Promise { + public async getRootProjects(): Promise { const rootElements = await this.getRootNodes(); if (rootElements[0] instanceof ProjectNode) { - return rootElements; + return rootElements.filter((node): node is ProjectNode => node instanceof ProjectNode); } else { - let result: ExplorerNode[] = []; + let result: ProjectNode[] = []; for (const rootWorkspace of rootElements) { const projects = await rootWorkspace.getChildren(); if (projects) { - result = result.concat(projects); + result = result.concat(projects.filter((node): node is ProjectNode => node instanceof ProjectNode)); } } return result; diff --git a/src/views/workspaceNode.ts b/src/views/workspaceNode.ts index 4cb8331f..c66bb724 100644 --- a/src/views/workspaceNode.ts +++ b/src/views/workspaceNode.ts @@ -1,13 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import { ThemeIcon } from "vscode"; +import { ThemeIcon, Uri } from "vscode"; import { Explorer } from "../constants"; import { Jdtls } from "../java/jdtls"; -import { INodeData } from "../java/nodeData"; +import { INodeData, NodeKind } from "../java/nodeData"; +import { Settings } from "../settings"; import { DataNode } from "./dataNode"; import { ExplorerNode } from "./explorerNode"; import { NodeFactory } from "./nodeFactory"; +import { createWorkspaceResourceNode, getWorkspaceResourceData } from "./workspaceResourceFolderNode"; export class WorkspaceNode extends DataNode { constructor(nodeData: INodeData, parent?: DataNode) { @@ -18,14 +20,25 @@ export class WorkspaceNode extends DataNode { if (!this.nodeData.uri) { return undefined; } - return Jdtls.getProjects(this.nodeData.uri); + const projects = await Jdtls.getProjects(this.nodeData.uri); + if (projects.length || Settings.nonJavaResourcesFiltered()) { + return projects; + } + + const workspaceUri = Uri.parse(this.nodeData.uri); + return getWorkspaceResourceData(workspaceUri, workspaceUri); } protected createChildNodeList(): ExplorerNode[] { const result: (ExplorerNode | undefined)[] = []; + const workspaceUri = this.uri ? Uri.parse(this.uri) : undefined; if (this.nodeData.children && this.nodeData.children.length) { this.nodeData.children.forEach((nodeData) => { - result.push(NodeFactory.createNode(nodeData, this)); + if (workspaceUri && (nodeData.kind === NodeKind.Folder || nodeData.kind === NodeKind.File)) { + result.push(createWorkspaceResourceNode(nodeData, this, workspaceUri)); + } else { + result.push(NodeFactory.createNode(nodeData, this)); + } }); } return result.filter((n?: T): n is T => Boolean(n)); diff --git a/src/views/workspaceResourceFolderNode.ts b/src/views/workspaceResourceFolderNode.ts new file mode 100644 index 00000000..f6eb0310 --- /dev/null +++ b/src/views/workspaceResourceFolderNode.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as minimatch from "minimatch"; +import * as path from "path"; +import { FileType, ThemeIcon, Uri, workspace } from "vscode"; +import { Explorer } from "../constants"; +import { INodeData, NodeKind } from "../java/nodeData"; +import { DataNode } from "./dataNode"; +import { ExplorerNode } from "./explorerNode"; +import { FileNode } from "./fileNode"; + +interface ISiblingClause { + when: string; +} + +interface IFilesExclude { + [pattern: string]: boolean | ISiblingClause; +} + +export async function getWorkspaceResourceData(directoryUri: Uri, workspaceFolderUri: Uri): Promise { + const entries = await workspace.fs.readDirectory(directoryUri); + const excludePatterns = workspace.getConfiguration("files", workspaceFolderUri) + .get("exclude", {}); + const siblingNames = new Set(entries.map(([name]) => name)); + + return entries + .map(([name, fileType]) => { + const uri = Uri.joinPath(directoryUri, name); + const relativePath = path.posix.relative(workspaceFolderUri.path, uri.path); + // VS Code's FileType is a bit mask, so symbolic-link directories can include both flags. + // tslint:disable-next-line:no-bitwise + const kind = (fileType & FileType.Directory) !== 0 ? NodeKind.Folder : NodeKind.File; + const nodeData: INodeData = { + name, + path: relativePath, + uri: uri.toString(), + kind, + }; + return { + name, + nodeData, + relativePath, + }; + }) + .filter(({ name, relativePath }) => !isExcluded(name, relativePath, siblingNames, excludePatterns)) + .map(({ nodeData }) => nodeData); +} + +export function createWorkspaceResourceNode( + nodeData: INodeData, + parent: DataNode, + workspaceFolderUri: Uri, +): ExplorerNode { + switch (nodeData.kind) { + case NodeKind.Folder: + return new WorkspaceResourceFolderNode(nodeData, parent, workspaceFolderUri); + case NodeKind.File: + return new WorkspaceResourceFileNode(nodeData, parent); + default: + throw new Error(`Unsupported workspace resource kind: ${nodeData.kind}`); + } +} + +export function isWorkspaceResourceNode(node: ExplorerNode): boolean { + return node instanceof WorkspaceResourceFolderNode || node instanceof WorkspaceResourceFileNode; +} + +class WorkspaceResourceFileNode extends FileNode { + protected get contextValue(): string { + return Explorer.ContextValueType.WorkspaceResourceFile; + } +} + +class WorkspaceResourceFolderNode extends DataNode { + constructor(nodeData: INodeData, parent: DataNode, private readonly workspaceFolderUri: Uri) { + super(nodeData, parent); + } + + protected async loadData(): Promise { + if (!this.uri) { + return []; + } + return getWorkspaceResourceData(Uri.parse(this.uri), this.workspaceFolderUri); + } + + protected createChildNodeList(): ExplorerNode[] { + return (this.nodeData.children || []).map((nodeData) => + createWorkspaceResourceNode(nodeData, this, this.workspaceFolderUri)); + } + + protected get iconPath(): ThemeIcon { + return ThemeIcon.Folder; + } + + protected get contextValue(): string { + return Explorer.ContextValueType.WorkspaceResourceFolder; + } +} + +function isExcluded( + name: string, + relativePath: string, + siblingNames: Set, + excludePatterns: IFilesExclude, +): boolean { + return Object.entries(excludePatterns).some(([pattern, value]) => { + if (!value || minimatch.match([relativePath], pattern, { dot: true }).length === 0) { + return false; + } + if (typeof value === "boolean") { + return value; + } + + const extension = path.posix.extname(name); + const basename = name.substring(0, name.length - extension.length); + return siblingNames.has(value.when.replace("$(basename)", basename)); + }); +} diff --git a/test/e2e-plans/java-dep-project-explorer.yaml b/test/e2e-plans/java-dep-project-explorer.yaml index 45ace756..82518e2f 100644 --- a/test/e2e-plans/java-dep-project-explorer.yaml +++ b/test/e2e-plans/java-dep-project-explorer.yaml @@ -7,6 +7,7 @@ # - revealInProjectExplorer reveals file from File Explorer context menu # - hideNonJavaResources removes non-Java workspace roots while preserving # Java roots across refreshes and newly imported projects +# - showNonJavaResources restores and expands filesystem-only workspace roots # # Usage: # npx autotest run test/e2e-plans/java-dep-project-explorer.yaml --vsix @@ -14,8 +15,8 @@ name: "Java Dependency — Project Explorer" description: | Tests the Java Projects explorer view: focus, link/unlink with editor, - reveal in project explorer, and filtering non-Java workspace roots without - hiding Java roots during refresh or project import. + reveal in project explorer, and hiding or expanding non-Java workspace roots + without disrupting Java roots during refresh or project import. setup: extension: "redhat.java" @@ -314,3 +315,16 @@ steps: level: 1 inView: "Java Projects" timeout: 30 + + - id: "expand-restored-non-java-workspace-root" + action: "expandTreeItem non-java" + + - id: "verify-non-java-workspace-resource" + action: "wait 1 seconds" + verifyTreeItem: + name: "package.json" + exact: true + count: 1 + level: 2 + inView: "Java Projects" + timeout: 15 diff --git a/test/multiple-suite/projectView.test.ts b/test/multiple-suite/projectView.test.ts index 3d31d685..ecdcce34 100644 --- a/test/multiple-suite/projectView.test.ts +++ b/test/multiple-suite/projectView.test.ts @@ -4,7 +4,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { - Commands, contextManager, DependencyExplorer, Jdtls, ProjectNode, WorkspaceNode, + Commands, contextManager, DependencyExplorer, FileNode, Jdtls, ProjectNode, WorkspaceNode, } from "../../extension.bundle"; import { setupTestEnv } from "../shared"; @@ -30,9 +30,18 @@ suite("Multiple Project View Tests", () => { const nonJavaRoot = roots?.find(root => root instanceof WorkspaceNode && root.name === "non-java") as WorkspaceNode | undefined; assert.ok(nonJavaRoot, "The non-Java workspace folder should have a root node"); - assert.equal((await nonJavaRoot!.getChildren()).length, 0, "The non-Java root should not contain Java projects"); + const nonJavaChildren = await nonJavaRoot.getChildren(); + const packageJson = nonJavaChildren.find((node) => node instanceof FileNode && node.name === "package.json"); + assert.ok(packageJson, "The non-Java workspace folder should expose its filesystem resources"); + assert.match( + (await packageJson.getTreeItem()).contextValue || "", + /java:workspaceResourceFile(?=.*?\b\+uri\b)/, + "Filesystem-backed resources should use a browse-only context", + ); const projects = await explorer.dataProvider.getRootProjects(); + assert.ok(projects.every((node) => node instanceof ProjectNode), + "Filesystem resources should not be returned as root projects"); const project = projects.find((node): node is ProjectNode => node instanceof ProjectNode && Boolean(node.uri)); assert.ok(project?.uri, "At least one Java project should be available"); @@ -81,6 +90,46 @@ suite("Multiple Project View Tests", () => { } }); + test("Applies boolean and conditional files.exclude to non-Java workspace folders", async function() { + const configuration = vscode.workspace.getConfiguration("files"); + const originalWorkspaceValue = configuration.inspect<{ + [pattern: string]: boolean | { when: string }; + }>("exclude")?.workspaceValue; + const nonJavaFolder = vscode.workspace.workspaceFolders?.find((folder) => folder.name === "non-java"); + assert.ok(nonJavaFolder, "The non-Java workspace folder should exist"); + const standaloneJs = vscode.Uri.joinPath(nonJavaFolder.uri, "standalone.js"); + const pairedJs = vscode.Uri.joinPath(nonJavaFolder.uri, "paired.js"); + const pairedTs = vscode.Uri.joinPath(nonJavaFolder.uri, "paired.ts"); + const temporaryFiles = [standaloneJs, pairedJs, pairedTs]; + + await Promise.all(temporaryFiles.map((uri) => vscode.workspace.fs.writeFile(uri, new Uint8Array()))); + try { + await configuration.update("exclude", { + "package.json": true, + "**/*.js": { when: "$(basename).ts" }, + }, vscode.ConfigurationTarget.Workspace); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + + const explorer = DependencyExplorer.getInstance(contextManager.context); + const roots = await explorer.dataProvider.getChildren(); + const nonJavaRoot = roots?.find(root => + root instanceof WorkspaceNode && root.name === "non-java") as WorkspaceNode | undefined; + assert.ok(nonJavaRoot, "The non-Java workspace folder should have a root node"); + const childNames = (await nonJavaRoot.getChildren()).map((node) => node.getDisplayName()); + assert.ok(!childNames.includes("package.json"), + "Excluded filesystem resources should not appear in the Java Projects explorer"); + assert.ok(childNames.includes("standalone.js"), + "A conditional exclusion should keep files whose sibling does not exist"); + assert.ok(!childNames.includes("paired.js"), + "A conditional exclusion should hide files whose configured sibling exists"); + assert.ok(childNames.includes("paired.ts"), "The matching sibling should remain visible"); + } finally { + await configuration.update("exclude", originalWorkspaceValue, vscode.ConfigurationTarget.Workspace); + await Promise.all(temporaryFiles.map((uri) => vscode.workspace.fs.delete(uri))); + await vscode.commands.executeCommand(Commands.VIEW_PACKAGE_REFRESH); + } + }); + test("Does not add project roots while cached multi-root roots are stale", async function() { const explorer = DependencyExplorer.getInstance(contextManager.context); const roots = await explorer.dataProvider.getChildren();