diff --git a/package.json b/package.json index 644628726b..c4247b441c 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,16 @@ "^pom\\.xml$", ".*\\.gradle(\\.kts)?$" ], + "javaBuildTools": [ + { + "displayName": "Maven", + "fileSearchPattern": "**/pom.xml" + }, + { + "displayName": "Gradle", + "fileSearchPattern": "**/{build,settings}.gradle*" + } + ], "semanticTokenTypes": [ { "id": "annotation", @@ -271,6 +281,20 @@ "description": "Traces the communication between VS Code and the Java language server.", "scope": "window" }, + "java.import.projectSelection": { + "type": "string", + "enum": [ + "manual", + "automatic" + ], + "enumDescriptions": [ + "Manually select the build configuration files.", + "Let extension automatically scan and select the build configuration files." + ], + "default": "automatic", + "markdownDescription": "[Experimental] Specifies how to select build configuration files to import. \nNote: Currently, `Gradle` projects cannot be partially imported.", + "scope": "window" + }, "java.import.maven.enabled": { "type": "boolean", "default": true, diff --git a/schemas/package.schema.json b/schemas/package.schema.json index a147e2d3a4..183026fcb6 100644 --- a/schemas/package.schema.json +++ b/schemas/package.schema.json @@ -21,6 +21,23 @@ "type": "string", "description": "Regular expressions for specifying build file" } + }, + "javaBuildTools": { + "type": "array", + "description": "Information about the cared build files. Will be used when 'java.import.projectSelection' is 'manual'.", + "items": { + "type": "object", + "properties": { + "displayName": { + "description": "The display name of the build file type.", + "type": "string" + }, + "fileSearchPattern": { + "description": "The glob pattern used to search the build files.", + "type": "string" + } + } + } } } } diff --git a/src/buildFilesSelector.ts b/src/buildFilesSelector.ts new file mode 100644 index 0000000000..0d2abdf906 --- /dev/null +++ b/src/buildFilesSelector.ts @@ -0,0 +1,210 @@ +import { ExtensionContext, MessageItem, QuickPickItem, QuickPickItemKind, Uri, WorkspaceFolder, extensions, window, workspace } from "vscode"; +import { getExclusionGlob as getExclusionGlobPattern } from "./utils"; +import * as path from "path"; + +export const PICKED_BUILD_FILES = "java.pickedBuildFiles"; +export class BuildFileSelector { + private buildTypes: IBuildTool[] = []; + private context: ExtensionContext; + private exclusionGlobPattern: string; + + constructor(context: ExtensionContext) { + this.context = context; + // TODO: should we introduce the exclusion globs into the contribution point? + this.exclusionGlobPattern = getExclusionGlobPattern(["**/target/**", "**/bin/**", "**/build/**"]); + for (const extension of extensions.all) { + const javaBuildTools: IBuildTool[] = extension.packageJSON.contributes?.javaBuildTools; + if (!Array.isArray(javaBuildTools)) { + continue; + } + + for (const buildType of javaBuildTools) { + if (!this.isValidBuildTypeConfiguration(buildType)) { + continue; + } + + this.buildTypes.push(buildType); + } + } + } + + /** + * @returns `true` if there are build files in the workspace, `false` otherwise. + */ + public async hasBuildFiles(): Promise { + for (const buildType of this.buildTypes) { + const uris: Uri[] = await workspace.findFiles(buildType.fileSearchPattern, this.exclusionGlobPattern, 1); + if (uris.length > 0) { + return true; + } + } + return false; + } + + /** + * Get the uri strings for the build files that the user selected. + * @returns An array of uri string for the build files that the user selected. + * An empty array means user canceled the selection. + */ + public async getBuildFiles(): Promise { + const cache = this.context.workspaceState.get(PICKED_BUILD_FILES); + if (cache !== undefined) { + return cache; + } + + const choice = await this.chooseBuildFilePickers(); + const pickedUris = await this.eliminateBuildToolConflict(choice); + if (pickedUris.length > 0) { + this.context.workspaceState.update(PICKED_BUILD_FILES, pickedUris); + } + return pickedUris; + } + + private isValidBuildTypeConfiguration(buildType: IBuildTool): boolean { + return !!buildType.displayName && !!buildType.fileSearchPattern; + } + + private async chooseBuildFilePickers(): Promise { + return window.showQuickPick(this.getBuildFilePickers(), { + placeHolder: "Note: Currently only Maven projects can be partially imported.", + title: "Select build files to import", + ignoreFocusOut: true, + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + }); + } + + /** + * Get pickers for all build files in the workspace. + */ + private async getBuildFilePickers(): Promise { + const addedFolders: Map = new Map(); + for (const buildType of this.buildTypes) { + const uris: Uri[] = await workspace.findFiles(buildType.fileSearchPattern, this.exclusionGlobPattern); + for (const uri of uris) { + const containingFolder = path.dirname(uri.fsPath); + if (addedFolders.has(containingFolder)) { + const picker = addedFolders.get(containingFolder); + if (!picker.buildTypeAndUri.has(buildType)) { + picker.detail += `, ./${workspace.asRelativePath(uri)}`; + picker.description += `, ${buildType.displayName}`; + picker.buildTypeAndUri.set(buildType, uri); + } + } else { + addedFolders.set(containingFolder, { + label: path.basename(containingFolder), + detail: `./${workspace.asRelativePath(uri)}`, + description: buildType.displayName, + buildTypeAndUri: new Map([[buildType, uri]]), + picked: true, + }); + } + } + } + const pickers: IBuildFilePicker[] = Array.from(addedFolders.values()); + return this.addSeparator(pickers); + } + + /** + * Add a separator pickers between pickers that belong to different workspace folders. + */ + private addSeparator(pickers: IBuildFilePicker[]): IBuildFilePicker[] { + // group pickers by their containing workspace folder + const workspaceFolders = new Map(); + for (const picker of pickers) { + const folder = workspace.getWorkspaceFolder(picker.buildTypeAndUri.values().next().value); + if (!folder) { + continue; + } + if (!workspaceFolders.has(folder)) { + workspaceFolders.set(folder, []); + } + workspaceFolders.get(folder)?.push(picker); + } + + const newPickers: IBuildFilePicker[] = []; + const folderArray = Array.from(workspaceFolders.keys()); + folderArray.sort((a, b) => a.name.localeCompare(b.name)); + for (const folder of folderArray) { + const pickersInFolder = workspaceFolders.get(folder); + newPickers.push({ + label: folder.name, + kind: QuickPickItemKind.Separator, + buildTypeAndUri: null + }); + newPickers.push(...this.sortPickers(pickersInFolder)); + } + return newPickers; + } + + private sortPickers(pickers: IBuildFilePicker[]): IBuildFilePicker[] { + return pickers.sort((a, b) => { + const pathA = path.dirname(a.buildTypeAndUri.values().next().value.fsPath); + const pathB = path.dirname(b.buildTypeAndUri.values().next().value.fsPath); + return pathA.localeCompare(pathB); + }); + } + + /** + * Ask user to choose a build tool when there are multiple build tools in the same folder. + */ + private async eliminateBuildToolConflict(choice?: IBuildFilePicker[]): Promise { + if (!choice) { + return []; + } + const conflictPickers = new Set(); + const result: string[] = []; + for (const picker of choice) { + if (picker.buildTypeAndUri.size > 1) { + conflictPickers.add(picker); + } else { + result.push(picker.buildTypeAndUri.values().next().value.toString()); + } + } + + if (conflictPickers.size > 0) { + for (const picker of conflictPickers) { + const conflictItems: IConflictItem[] = [{ + title: "Skip", + isCloseAffordance: true, + }]; + for (const buildType of picker.buildTypeAndUri.keys()) { + conflictItems.push({ + title: buildType.displayName, + uri: picker.buildTypeAndUri.get(buildType), + }); + } + const choice = await window.showInformationMessage( + `Which build tool would you like to use for folder: ${picker.label}?`, + { + modal: true, + }, + ...conflictItems + ); + + if (choice?.title !== "Skip" && choice?.uri) { + result.push(choice.uri.toString()); + } + } + } + return result; + } +} + +interface IBuildTool { + displayName: string; + fileSearchPattern: string; +} + +interface IConflictItem extends MessageItem { + uri?: Uri; +} + +interface IBuildFilePicker extends QuickPickItem { + buildTypeAndUri: Map; +} + +export function cleanupProjectPickerCache(context: ExtensionContext) { + context.workspaceState.update(PICKED_BUILD_FILES, undefined); +} diff --git a/src/extension.ts b/src/extension.ts index 29f155870b..5286b6c548 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; -import { CodeActionContext, commands, ConfigurationTarget, Diagnostic, env, EventEmitter, ExtensionContext, extensions, IndentAction, InputBoxOptions, languages, RelativePattern, TextDocument, UIKind, Uri, ViewColumn, window, workspace, WorkspaceConfiguration, ProgressLocation, Position, Selection, Range } from 'vscode'; +import { CodeActionContext, commands, ConfigurationTarget, Diagnostic, env, EventEmitter, ExtensionContext, extensions, IndentAction, InputBoxOptions, languages, RelativePattern, TextDocument, UIKind, Uri, ViewColumn, window, workspace, WorkspaceConfiguration, version } from 'vscode'; import { CancellationToken, CodeActionParams, CodeActionRequest, Command, CompletionRequest, DidChangeConfigurationNotification, ExecuteCommandParams, ExecuteCommandRequest, LanguageClientOptions, RevealOutputChannelOn } from 'vscode-languageclient'; import { LanguageClient } from 'vscode-languageclient/node'; import { apiManager } from './apiManager'; @@ -24,18 +24,19 @@ import { initialize as initializeRecommendation } from './recommendation'; import * as requirements from './requirements'; import { runtimeStatusBarProvider } from './runtimeStatusBarProvider'; import { serverStatusBarProvider } from './serverStatusBarProvider'; -import { ACTIVE_BUILD_TOOL_STATE, cleanWorkspaceFileName, getJavaServerMode, handleTextDocumentChanges, onConfigurationChange, ServerMode } from './settings'; +import { ACTIVE_BUILD_TOOL_STATE, cleanWorkspaceFileName, getJavaServerMode, handleTextDocumentChanges, getImportMode, onConfigurationChange, ServerMode, ImportMode } from './settings'; import { snippetCompletionProvider } from './snippetCompletionProvider'; import { JavaClassEditorProvider } from './javaClassEditor'; import { StandardLanguageClient } from './standardLanguageClient'; import { SyntaxLanguageClient } from './syntaxLanguageClient'; -import { convertToGlob, deleteClientLog, deleteDirectory, ensureExists, getBuildFilePatterns, getExclusionBlob, getInclusionPatternsFromNegatedExclusion, getJavaConfig, getJavaConfiguration, hasBuildToolConflicts } from './utils'; +import { convertToGlob, deleteClientLog, deleteDirectory, ensureExists, getBuildFilePatterns, getExclusionGlob, getInclusionPatternsFromNegatedExclusion, getJavaConfig, getJavaConfiguration, hasBuildToolConflicts } from './utils'; import glob = require('glob'); import { Telemetry } from './telemetry'; import { getMessage } from './errorUtils'; import { TelemetryService } from '@redhat-developer/vscode-redhat-telemetry/lib'; import { activationProgressNotification } from "./serverTaskPresenter"; import { loadSupportedJreNames } from './jdkUtils'; +import { BuildFileSelector, cleanupProjectPickerCache } from './buildFilesSelector'; const syntaxClient: SyntaxLanguageClient = new SyntaxLanguageClient(); const standardClient: StandardLanguageClient = new StandardLanguageClient(); @@ -323,6 +324,7 @@ export async function activate(context: ExtensionContext): Promise const data = {}; try { cleanupLombokCache(context); + cleanupProjectPickerCache(context); deleteDirectory(workspacePath); deleteDirectory(syntaxServerWorkspacePath); } catch (error) { @@ -381,7 +383,7 @@ export async function activate(context: ExtensionContext): Promise } const api: ExtensionAPI = apiManager.getApiInstance(); - if (api.serverMode === switchTo || api.serverMode === ServerMode.standard) { + if (!force && (api.serverMode === switchTo || api.serverMode === ServerMode.standard)) { return; } @@ -467,9 +469,23 @@ async function startStandardServer(context: ExtensionContext, requirements: requ return; } - const checkConflicts: boolean = await ensureNoBuildToolConflicts(context, clientOptions); - if (!checkConflicts) { - return; + const selector: BuildFileSelector = new BuildFileSelector(context); + const importMode: ImportMode = await getImportMode(context, selector); + if (importMode === ImportMode.automatic) { + if (!await ensureNoBuildToolConflicts(context, clientOptions)) { + return; + } + } else { + const buildFiles: string[] = []; + if (importMode === ImportMode.manual) { + buildFiles.push(...await selector.getBuildFiles()); + } + if (buildFiles.length === 0) { + // cancelled by user + serverStatusBarProvider.showNotImportedStatus(); + return; + } + clientOptions.initializationOptions.projectConfigurations = buildFiles; } if (apiManager.getApiInstance().serverMode === ServerMode.lightWeight) { @@ -496,7 +512,7 @@ async function workspaceContainsBuildFiles(): Promise { // Nothing found in negated exclusion pattern, do a normal search then. const inclusionBlob: string = convertToGlob(inclusionPatterns); - const exclusionBlob: string = getExclusionBlob(); + const exclusionBlob: string = getExclusionGlob(); if (inclusionBlob && (await workspace.findFiles(inclusionBlob, exclusionBlob, 1 /* maxResults */)).length > 0) { return true; } @@ -922,8 +938,7 @@ async function getTriggerFiles(): Promise { } // Paths set by 'java.import.exclusions' will be ignored when searching trigger files. - const exclusionGlob = getExclusionBlob(); - + const exclusionGlob = getExclusionGlob(); const javaFilesUnderRoot: Uri[] = await workspace.findFiles(new RelativePattern(rootFolder, "*.java"), exclusionGlob, 1); for (const javaFile of javaFilesUnderRoot) { if (isPrefix(rootPath, javaFile.fsPath)) { diff --git a/src/languageStatusItemFactory.ts b/src/languageStatusItemFactory.ts index 12c310ec86..19aa99b3e9 100644 --- a/src/languageStatusItemFactory.ts +++ b/src/languageStatusItemFactory.ts @@ -22,7 +22,7 @@ export namespace StatusCommands { export const switchToStandardCommand = { title: "Load Projects", command: Commands.SWITCH_SERVER_MODE, - arguments: ["Standard", true], + arguments: ['Standard', true], tooltip: "LightWeight mode only provides limited features, please load projects to get full feature set" }; @@ -38,6 +38,13 @@ export namespace StatusCommands { arguments: ["java.configuration.runtimes"], tooltip: "Configure Java Runtime" }; + + export const startStandardServerCommand = { + title: "Load Projects", + command: Commands.SWITCH_SERVER_MODE, + arguments: ['Standard', true], + tooltip: "Load Projects" + }; } export namespace ServerStatusItemFactory { @@ -57,6 +64,13 @@ export namespace ServerStatusItemFactory { item.command = StatusCommands.switchToStandardCommand; } + export function showNotImportedStatus(item: any): void { + item.severity = vscode.LanguageStatusSeverity?.Warning; + item.text = StatusIcon.notImported; + item.detail = "No projects are Imported"; + item.command = StatusCommands.startStandardServerCommand; + } + export function showStandardStatus(item: any): void { item.severity = vscode.LanguageStatusSeverity?.Information; item.command = StatusCommands.showServerStatusCommand; diff --git a/src/serverStatusBarProvider.ts b/src/serverStatusBarProvider.ts index 57003877cc..12c4fa2168 100644 --- a/src/serverStatusBarProvider.ts +++ b/src/serverStatusBarProvider.ts @@ -43,6 +43,20 @@ class ServerStatusBarProvider implements Disposable { } } + public showNotImportedStatus(): void { + if (supportsLanguageStatus()) { + ServerStatusItemFactory.showNotImportedStatus(this.languageStatusItem); + } else { + if (this.isAdvancedStatusBarItem) { + (this.statusBarItem as any).name = "No projects are imported"; + } + this.statusBarItem.text = StatusIcon.notImported; + this.statusBarItem.command = StatusCommands.startStandardServerCommand; + this.statusBarItem.tooltip = "No projects are imported, click to load projects"; + this.statusBarItem.show(); + } + } + public showStandardStatus(): void { if (supportsLanguageStatus()) { ServerStatusItemFactory.showStandardStatus(this.languageStatusItem); @@ -112,7 +126,8 @@ export enum StatusIcon { busy = "$(sync~spin)", ready = "$(thumbsup)", warning = "$(thumbsdown)", - error = "$(thumbsdown)" + error = "$(thumbsdown)", + notImported = "$(info)" } export const serverStatusBarProvider: ServerStatusBarProvider = new ServerStatusBarProvider(); diff --git a/src/settings.ts b/src/settings.ts index b79c6f3c9b..c601ff0303 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -8,6 +8,7 @@ import { cleanupLombokCache } from './lombokSupport'; import { ensureExists, getJavaConfiguration } from './utils'; import { apiManager } from './apiManager'; import { isActive, setActive, smartSemicolonDetection } from './smartSemicolonDetection'; +import { BuildFileSelector, PICKED_BUILD_FILES } from './buildFilesSelector'; const DEFAULT_HIDDEN_FILES: string[] = ['**/.classpath', '**/.project', '**/.settings', '**/.factorypath']; const IS_WORKSPACE_JDK_ALLOWED = "java.ls.isJdkAllowed"; @@ -366,4 +367,38 @@ export function handleTextDocumentChanges(document: TextDocument, changes: reado } } +export async function getImportMode(context: ExtensionContext, selector: BuildFileSelector): Promise { + const mode = getJavaConfiguration().get("import.projectSelection"); + if (mode === "manual") { + // if no selectable build files, use automatic mode + const hasBuildFiles = await selector.hasBuildFiles(); + if (!hasBuildFiles) { + return ImportMode.automatic; + } + + // If the the manually picked build files has already cached, return manual mode. + if (context.workspaceState.get(PICKED_BUILD_FILES) !== undefined) { + return ImportMode.manual; + } + + const answer: string = await window.showInformationMessage( + "Java build files are detected in the workspace. How do you want to import them?", + { modal: true }, + "Import All", "Let Me Select"); + if (answer === "Import All") { + return ImportMode.automatic; + } else if (answer === "Let Me Select") { + return ImportMode.manual; + } + return ImportMode.skip; + } + + return ImportMode.automatic; +} + +export enum ImportMode { + automatic = 'automatic', + manual = 'manual', + skip = 'skip', +} diff --git a/src/utils.ts b/src/utils.ts index b68b010bb9..5ca920ee27 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -110,7 +110,11 @@ export function convertToGlob(filePatterns: string[], basePatterns?: string[]): return parseToStringGlob(patterns); } -export function getExclusionBlob(): string { +/** + * Merge the values of setting 'java.import.exclusions' into one glob pattern. + * @param additionalExclusions Additional exclusions to be merged into the glob pattern. + */ +export function getExclusionGlob(additionalExclusions?: string[]): string { const config = getJavaConfiguration(); const exclusions: string[] = config.get("import.exclusions", []); const patterns: string[] = []; @@ -121,6 +125,9 @@ export function getExclusionBlob(): string { patterns.push(exclusion); } + if (additionalExclusions) { + patterns.push(...additionalExclusions); + } return parseToStringGlob(patterns); } @@ -173,10 +180,10 @@ async function getBuildFilesInWorkspace(): Promise { buildFiles.push(...await workspace.findFiles(convertToGlob(inclusionFilePatterns, inclusionFolderPatterns), null /* force not use default exclusion */)); } - const inclusionBlob: string = convertToGlob(inclusionFilePatterns); - const exclusionBlob: string = getExclusionBlob(); - if (inclusionBlob) { - buildFiles.push(...await workspace.findFiles(inclusionBlob, exclusionBlob)); + const inclusionGlob: string = convertToGlob(inclusionFilePatterns); + const exclusionGlob: string = getExclusionGlob(); + if (inclusionGlob) { + buildFiles.push(...await workspace.findFiles(inclusionGlob, exclusionGlob)); } return buildFiles; diff --git a/test/standard-mode-suite/extension.test.ts b/test/standard-mode-suite/extension.test.ts index cfa9db42c0..3fd5828b71 100644 --- a/test/standard-mode-suite/extension.test.ts +++ b/test/standard-mode-suite/extension.test.ts @@ -54,6 +54,7 @@ suite('Java Language Extension - Standard', () => { Commands.CLIPBOARD_ONPASTE, Commands.COMPILE_WORKSPACE, Commands.CONFIGURATION_UPDATE, + "java.action.configureFavoriteStaticMembers", Commands.CREATE_MODULE_INFO, Commands.CREATE_MODULE_INFO_COMMAND, Commands.EXECUTE_WORKSPACE_COMMAND, diff --git a/test/standard-mode-suite/utils.test.ts b/test/standard-mode-suite/utils.test.ts index 7659ba2c32..8ec72f1b8a 100644 --- a/test/standard-mode-suite/utils.test.ts +++ b/test/standard-mode-suite/utils.test.ts @@ -1,7 +1,7 @@ 'use strict'; import * as assert from 'assert'; -import { getJavaConfiguration, getBuildFilePatterns, getInclusionPatternsFromNegatedExclusion, getExclusionBlob, convertToGlob } from '../../src/utils'; +import { getJavaConfiguration, getBuildFilePatterns, getInclusionPatternsFromNegatedExclusion, getExclusionGlob, convertToGlob } from '../../src/utils'; import { WorkspaceConfiguration } from 'vscode'; import { listJdks } from '../../src/jdkUtils'; import { platform } from 'os'; @@ -77,7 +77,7 @@ suite('Utils Test', () => { assert.deepEqual(result, ["**/node_modules/test/**"]); }); - test('getExclusionBlob() - no negated exclusions', async function () { + test('getExclusionGlob() - no negated exclusions', async function () { await config.update(IMPORT_EXCLUSION, [ "**/node_modules/**", "**/.metadata/**", @@ -85,12 +85,12 @@ suite('Utils Test', () => { "**/META-INF/maven/**" ]); - const result: string = getExclusionBlob(); + const result: string = getExclusionGlob(); assert.equal(result, "{**/node_modules/**,**/.metadata/**,**/archetype-resources/**,**/META-INF/maven/**}"); }); - test('getExclusionBlob() - has negated exclusions', async function () { + test('getExclusionGlob() - has negated exclusions', async function () { await config.update(IMPORT_EXCLUSION, [ "**/node_modules/**", "!**/node_modules/test/**", @@ -99,7 +99,7 @@ suite('Utils Test', () => { "**/META-INF/maven/**" ]); - const result: string = getExclusionBlob(); + const result: string = getExclusionGlob(); assert.equal(result, "{**/node_modules/**,**/.metadata/**,**/archetype-resources/**,**/META-INF/maven/**}"); });