diff --git a/Changelog.md b/Changelog.md index a1b3ba99..695881c1 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,8 @@ ## 1.9.0 +- Add button to create new Camel project while no folder/workspace is opened. + ## 1.8.0 - Provide folder selection when using `Create Camel Quarkus/SpringBoot Project` command diff --git a/package.json b/package.json index 3d1325a3..b8d4b918 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,12 @@ "category": "Camel", "enablement": "workspaceFolderCount != 0" }, + { + "command": "camel.jbang.project.new", + "title": "Create a Camel project", + "category": "Camel", + "enablement": "false" + }, { "command": "camel.jbang.project.quarkus.new", "title": "Create a Camel Quarkus project", @@ -328,6 +334,12 @@ "id": "camel.new.file", "label": "New Camel File" } + ], + "viewsWelcome": [ + { + "view": "workbench.explorer.emptyView", + "contents": "Create a new Camel project using the button below.\n[Create a Camel project](command:camel.jbang.project.new)\nLearn more about Language Support for Apache Camel by Red Hat by reading the [documentation](https://camel-tooling.github.io/camel-lsp-client-vscode/)" + } ] }, "scripts": { diff --git a/src/commands/AbstractNewCamelProjectCommand.ts b/src/commands/AbstractNewCamelProjectCommand.ts new file mode 100644 index 00000000..36afa715 --- /dev/null +++ b/src/commands/AbstractNewCamelProjectCommand.ts @@ -0,0 +1,176 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License", destination); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +import * as path from "path"; +import { TaskScope, Uri, commands, window, workspace } from "vscode"; +import { CamelExportJBangTask } from "../tasks/CamelExportJBangTask"; +import { arePathsEqual, getCurrentWorkingDirectory } from "../requirements/utils"; + +export abstract class AbstractNewCamelProjectCommand { + + async create() { + const runtime = await this.getRuntime(); + const input = await this.askForGAV(); + if (runtime && input) { + + // Uses the user selected folder otherwise default to the root workspace folder + const outputFolder = await this.showDialogToPickFolder(); + if (!outputFolder) { + await window.showErrorMessage('Camel project creation canceled or invalid folder selection'); + return; + } + + // If the chosen ouputh folder is diferent from the first folder from the current workspace warns the user about + // potentially deleting files in the selected folder. + // Executing the command from the same folder does not delete files. + const cwd = getCurrentWorkingDirectory(); + if (cwd && !arePathsEqual(cwd, outputFolder.fsPath)){ + const userChoice = await this.confirmDestructiveActionInSelectedFolder(outputFolder.fsPath); + + if (userChoice === undefined){ + await window.showInformationMessage('Camel project creation canceled'); + return; + } + } + + await new CamelExportJBangTask(TaskScope.Workspace, input, runtime, outputFolder.fsPath).execute(); + + // if not exist, init .vscode with tasks.json and launch.json config files + await workspace.fs.createDirectory(Uri.file(path.join(outputFolder.fsPath, '.vscode'))); + for (const filename of ['tasks', 'launch']) { + await this.copyFile(`../../../resources/${runtime}/${filename}.json`, path.join(outputFolder.fsPath, `.vscode/${filename}.json`)); + } + + // open the newly created project in a new vscode instance + await commands.executeCommand('vscode.openFolder', outputFolder, true); + + } + } + + abstract getRuntime(): Promise; + + async askForGAV() { + return await window.showInputBox({ + prompt: 'Please provide repository coordinate', + placeHolder: 'com.acme:myproject:1.0-SNAPSHOT', + value: 'com.acme:myproject:1.0-SNAPSHOT', + validateInput: (gav) => { + return this.validateGAV(gav); + }, + }); + } + + /** + * Maven GAV validation + * - no empty name + * - Have 2 double-dots (similar check than Camel JBang) + * - following mostly recommendations from Maven Central for name rules + * + * @param name + * @returns string | undefined + */ + public validateGAV(name: string): string | undefined { + if (!name) { + return 'Please provide a GAV for the new project following groupId:artifactId:version pattern.'; + } + if (name.includes(' ')) { + return 'The GAV cannot contain a space. It must constituted from groupId, artifactId and version following groupId:artifactId:version pattern.'; + } + const gavs = name.split(':'); + if (gavs.length !== 3) { + return 'The GAV needs to have double-dot `:` separator and constituted from groupId, artifactId and version'; + } + const groupIdSplit = gavs[0].split('.'); + if (groupIdSplit[0].length === 0) { + return 'The group id cannot start with a .'; + } + for (const groupidSubPart of groupIdSplit) { + const regExpSearch = /^[a-z]\w*$/.exec(groupidSubPart); + if (regExpSearch === null || regExpSearch.length === 0) { + return `Invalid subpart of group Id: ${groupidSubPart}} . It must follow groupId:artifactId:version pattern with group Id subpart separated by dot needs to follow this specific pattern: [a-zA-Z]\\w*`; + } + } + + const artifactId = gavs[1]; + const regExpSearchArtifactId = /^[a-zA-Z]\w*$/.exec(artifactId); + if (regExpSearchArtifactId === null || regExpSearchArtifactId.length === 0) { + return `Invalid artifact Id: ${artifactId}} . It must follow groupId:artifactId:version pattern with artifactId specific pattern: [a-zA-Z]\\w*`; + } + + const version = gavs[2]; + const regExpSearch = /^\d[\w-.]*$/.exec(version); + if (regExpSearch === null || regExpSearch.length === 0) { + return `Invalid version: ${version} . It must follow groupId:artifactId:version pattern with version specific pattern: \\d[\\w-.]*`; + } + return undefined; + } + + /** + * Open a dialog to select a folder to create the project in. + * + * @returns Uri of the selected folder or undefined if canceled by the user. + */ + private async showDialogToPickFolder(): Promise { + const selectedFolders = await window.showOpenDialog( + { + canSelectMany: false, + canSelectFolders: true, + canSelectFiles: false, + openLabel: 'Select', + title: 'Select a folder to create the project in. ESC to cancel the project creation' + }); + if (selectedFolders !== undefined) { + return selectedFolders[0]; + } + return undefined; + } + + /** + * Handles copy of the resources from the extension to the vscode workspace + * + * @param sourcePath Path of source + * @param destPath Path of destination + */ + private async copyFile(sourcePath: string, destPath: string): Promise { + + const sourcePathUri = Uri.file(path.resolve(__dirname, sourcePath)); + const destPathUri = Uri.file(path.resolve(__dirname, destPath)); + try { + await workspace.fs.copy(sourcePathUri, destPathUri, { overwrite: false }); + } catch (error) { + // Do nothing in case there already exists tasks.json and launch.json files + } + + } + + /** + * Shows a modal asking for user confirmation of a potential desctructive action in the selected folder. + * VSCode automatically provides a 'Cancel' option which return `undefined`. The continue option will return the string `Continue`. + * + * @param outputPath path to be shown in the warning message. + * + * @returns string | undefined + */ + private async confirmDestructiveActionInSelectedFolder(outputPath: string) { + const message = `Files in the folder: ${outputPath} WILL BE DELETED before project creation, continue?`; + const continueOption = 'Continue'; + + return await window.showWarningMessage(message, { modal: true }, continueOption); + + } +} diff --git a/src/commands/NewCamelProjectCommand.ts b/src/commands/NewCamelProjectCommand.ts index c063c596..57df7c10 100644 --- a/src/commands/NewCamelProjectCommand.ts +++ b/src/commands/NewCamelProjectCommand.ts @@ -16,161 +16,17 @@ */ 'use strict'; -import * as path from "path"; -import { TaskScope, Uri, commands, window, workspace } from "vscode"; -import { CamelExportJBangTask } from "../tasks/CamelExportJBangTask"; -import { arePathsEqual, getCurrentWorkingDirectory } from "../requirements/utils"; +import { window } from "vscode"; +import { AbstractNewCamelProjectCommand } from "./AbstractNewCamelProjectCommand"; +export class NewCamelProjectCommand extends AbstractNewCamelProjectCommand { -export abstract class NewCamelProjectCommand { + public static readonly ID_COMMAND_CAMEL_PROJECT = 'camel.jbang.project.new'; - async create() { - const input = await this.askForGAV(); - if (input) { - - // Uses the user selected folder otherwise default to the root workspace folder. - const outputFolder = await this.showDialogToPickFolder(); - if (!outputFolder) { - await window.showErrorMessage('Camel project creation canceled or invalid folder selection'); - return; - } - - // If the chosen ouputh folder is diferent from the first folder from the current workspace warns the user about - // potentially deleting files in the selected folder. - // Executing the command from the same folder does not delete files. - const cwd = getCurrentWorkingDirectory(); - if (cwd && !arePathsEqual(cwd, outputFolder.fsPath)){ - const userChoice = await this.confirmDestructiveActionInSelectedFolder(outputFolder.fsPath); - - if (userChoice === undefined){ - await window.showInformationMessage('Camel project creation canceled'); - return; - } - } - - const runtime = this.getRuntime(); - await new CamelExportJBangTask(TaskScope.Workspace, input, runtime, outputFolder.fsPath).execute(); - - // if not exist, init .vscode with tasks.json and launch.json config files - await workspace.fs.createDirectory(Uri.file(path.join(outputFolder.fsPath, '.vscode'))); - for (const filename of ['tasks', 'launch']) { - await this.copyFile(`../../../resources/${runtime}/${filename}.json`, path.join(outputFolder.fsPath, `.vscode/${filename}.json`)); - } - - // open the newly created project in a new vscode instance - await commands.executeCommand('vscode.openFolder', outputFolder, true); - - } - } - - abstract getRuntime(): string; - - async askForGAV() { - return await window.showInputBox({ - prompt: 'Please provide repository coordinate', - placeHolder: 'com.acme:myproject:1.0-SNAPSHOT', - value: 'com.acme:myproject:1.0-SNAPSHOT', - validateInput: (gav) => { - return this.validateGAV(gav); - }, + async getRuntime(): Promise { + const options = ['quarkus', 'spring-boot']; + const selected = await window.showQuickPick(options, { + placeHolder: 'Select a runtime option', }); - } - - /** - * Maven GAV validation - * - no empty name - * - Have 2 double-dots (similar check than Camel JBang) - * - following mostly recommendations from Maven Central for name rules - * - * @param name - * @returns string | undefined - */ - public validateGAV(name: string): string | undefined { - if (!name) { - return 'Please provide a GAV for the new project following groupId:artifactId:version pattern.'; - } - if (name.includes(' ')) { - return 'The GAV cannot contain a space. It must constituted from groupId, artifactId and version following groupId:artifactId:version pattern.'; - } - const gavs = name.split(':'); - if (gavs.length !== 3) { - return 'The GAV needs to have double-dot `:` separator and constituted from groupId, artifactId and version'; - } - const groupIdSplit = gavs[0].split('.'); - if (groupIdSplit[0].length === 0) { - return 'The group id cannot start with a .'; - } - for (const groupidSubPart of groupIdSplit) { - const regExpSearch = /^[a-z]\w*$/.exec(groupidSubPart); - if (regExpSearch === null || regExpSearch.length === 0) { - return `Invalid subpart of group Id: ${groupidSubPart}} . It must follow groupId:artifactId:version pattern with group Id subpart separated by dot needs to follow this specific pattern: [a-zA-Z]\\w*`; - } - } - - const artifactId = gavs[1]; - const regExpSearchArtifactId = /^[a-zA-Z]\w*$/.exec(artifactId); - if (regExpSearchArtifactId === null || regExpSearchArtifactId.length === 0) { - return `Invalid artifact Id: ${artifactId}} . It must follow groupId:artifactId:version pattern with artifactId specific pattern: [a-zA-Z]\\w*`; - } - - const version = gavs[2]; - const regExpSearch = /^\d[\w-.]*$/.exec(version); - if (regExpSearch === null || regExpSearch.length === 0) { - return `Invalid version: ${version} . It must follow groupId:artifactId:version pattern with version specific pattern: \\d[\\w-.]*`; - } - return undefined; - } - - /** - * Open a dialog to select a folder to create the project in. - * - * @returns Uri of the selected folder or undefined if canceled by the user. - */ - private async showDialogToPickFolder(): Promise { - const selectedFolders = await window.showOpenDialog( - { - canSelectMany: false, - canSelectFolders: true, - canSelectFiles: false, - openLabel: 'Select', - title: 'Select a folder to create the project in. ESC to cancel the project creation' - }); - if (selectedFolders !== undefined) { - return selectedFolders[0]; - } - return undefined; - } - - /** - * Handles copy of the resources from the extension to the vscode workspace - * - * @param sourcePath Path of source - * @param destPath Path of destination - */ - private async copyFile(sourcePath: string, destPath: string): Promise { - - const sourcePathUri = Uri.file(path.resolve(__dirname, sourcePath)); - const destPathUri = Uri.file(path.resolve(__dirname, destPath)); - try { - await workspace.fs.copy(sourcePathUri, destPathUri, { overwrite: false }); - } catch (error) { - // Do nothing in case there already exists tasks.json and launch.json files - } - - } - - /** - * Shows a modal asking for user confirmation of a potential desctructive action in the selected folder. - * VSCode automatically provides a 'Cancel' option which return `undefined`. The continue option will return the string `Continue`. - * - * @param outputPath path to be shown in the warning message. - * - * @returns string | undefined - */ - private async confirmDestructiveActionInSelectedFolder(outputPath: string) { - const message = `Files in the folder: ${outputPath} WILL BE DELETED before project creation, continue?`; - const continueOption = 'Continue'; - - return await window.showWarningMessage(message, { modal: true }, continueOption); - + return selected ?? undefined; } } diff --git a/src/commands/NewCamelQuarkusProjectCommand.ts b/src/commands/NewCamelQuarkusProjectCommand.ts index 367d9885..9b2a68a5 100644 --- a/src/commands/NewCamelQuarkusProjectCommand.ts +++ b/src/commands/NewCamelQuarkusProjectCommand.ts @@ -16,13 +16,13 @@ */ 'use strict'; -import { NewCamelProjectCommand } from "./NewCamelProjectCommand"; +import { AbstractNewCamelProjectCommand } from "./AbstractNewCamelProjectCommand"; -export class NewCamelQuarkusProjectCommand extends NewCamelProjectCommand { +export class NewCamelQuarkusProjectCommand extends AbstractNewCamelProjectCommand { public static readonly ID_COMMAND_CAMEL_QUARKUS_PROJECT = 'camel.jbang.project.quarkus.new'; - getRuntime(): string { + async getRuntime(): Promise { return 'quarkus'; } } diff --git a/src/commands/NewCamelSpringBootProjectCommand.ts b/src/commands/NewCamelSpringBootProjectCommand.ts index e1308dbf..b20c5cca 100644 --- a/src/commands/NewCamelSpringBootProjectCommand.ts +++ b/src/commands/NewCamelSpringBootProjectCommand.ts @@ -16,13 +16,13 @@ */ 'use strict'; -import { NewCamelProjectCommand } from "./NewCamelProjectCommand"; +import { AbstractNewCamelProjectCommand } from "./AbstractNewCamelProjectCommand"; -export class NewCamelSpringBootProjectCommand extends NewCamelProjectCommand { +export class NewCamelSpringBootProjectCommand extends AbstractNewCamelProjectCommand { public static readonly ID_COMMAND_CAMEL_SPRINGBOOT_PROJECT = 'camel.jbang.project.springboot.new'; - getRuntime(): string { + async getRuntime(): Promise { return 'spring-boot'; } } diff --git a/src/extension.ts b/src/extension.ts index c2fbe8b4..360b2a58 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import * as telemetry from './Telemetry'; import { NewCamelFileCommand } from './commands/NewCamelFileCommand'; import { NewCamelKameletCommand } from './commands/NewCamelKameletCommand'; import { NewCamelPipeCommand } from './commands/NewCamelPipeCommand'; +import { NewCamelProjectCommand } from './commands/NewCamelProjectCommand'; import { NewCamelQuarkusProjectCommand } from './commands/NewCamelQuarkusProjectCommand'; import { NewCamelRouteCommand } from './commands/NewCamelRouteCommand'; import { NewCamelRouteFromOpenAPICommand } from './commands/NewCamelRouteFromOpenAPICommand'; @@ -167,6 +168,10 @@ export async function activate(context: ExtensionContext) { await sendCommandTrackingEvent(NewCamelPipeCommand.ID_COMMAND_CAMEL_ROUTE_PIPE_YAML); })); + context.subscriptions.push(commands.registerCommand(NewCamelProjectCommand.ID_COMMAND_CAMEL_PROJECT, async () => { + await new NewCamelProjectCommand().create(); + await sendCommandTrackingEvent(NewCamelProjectCommand.ID_COMMAND_CAMEL_PROJECT); + })); context.subscriptions.push(commands.registerCommand(NewCamelQuarkusProjectCommand.ID_COMMAND_CAMEL_QUARKUS_PROJECT, async () => { await new NewCamelQuarkusProjectCommand().create(); await sendCommandTrackingEvent(NewCamelQuarkusProjectCommand.ID_COMMAND_CAMEL_QUARKUS_PROJECT); diff --git a/src/test/suite/camel.project.command.test.ts b/src/test/suite/camel.project.command.test.ts index f29479d4..28849ec1 100644 --- a/src/test/suite/camel.project.command.test.ts +++ b/src/test/suite/camel.project.command.test.ts @@ -24,8 +24,9 @@ describe('Should validate Create a Camel Project command', function () { const COMMANDS = [new NewCamelQuarkusProjectCommand(), new NewCamelSpringBootProjectCommand()]; - COMMANDS.forEach(command => { - context(`GAV validation of ${command.getRuntime()}`, function () { + COMMANDS.forEach(async command => { + const runtime = await command.getRuntime(); + context(`GAV validation of ${runtime}`, function () { it('Validate ok', function () { expect(command.validateGAV('com.test:demo:1.0-SNAPSHOT')).to.be.undefined;