diff --git a/vscode-extensions/vscode-spring-cli/README.md b/vscode-extensions/vscode-spring-cli/README.md new file mode 100644 index 000000000..52540e44d --- /dev/null +++ b/vscode-extensions/vscode-spring-cli/README.md @@ -0,0 +1,59 @@ +# Spring CLI Extension + +The extension integrates into Visual Studio Code UI [Spring CLI](https://docs.spring.io/spring-cli/reference/) which increases productivity when new Spring Boot projects are created or new functionality added to existing projects. + +Easy way to add new functionality to Spring Boot projects from predefined projects registered with Spring CLI or from Gen AI by right-clikcing on the project's `pom.xml` + +![Add-Functionality][Add-Functionality] + +Easy access to the Gen AI generated Mardown file with an easy way to apply it: + +![Gen-AI-Markdown][Gen-AI-markdown] + +Applying the Gen AI response markdown guide to a project shows a diff view of what is about to happen to the project and can be undone via IDE provided "Undo" functionality + +![Prewview-Changes][Preview-Changes] + +Furthermore, managing Spring CLI projects, projects catalogs and user defined commands is nicely wrapped into VSCode quick-pick UI elements via commands. + +## Requirements +**IMPORTANT** The extension requires Spring CLI to be installed on your system. See [Spring CLI Installation Guide](https://docs.spring.io/spring-cli/reference/installation.html). If Spring CLI executable is on the `PATH` environment variable then please use Spring CLI Extension setting `spring-cli.executable` to specify the path to Spring CLI executable file. + +## Supported Features + +Create new Spring Boot project using predefined Spring Boot projects known to Spring CLI (`boot new` CLI command). The command is available via: +- Command Palette: **Spring CLI: New Boot Project** + +Add functionality to a Spring Boot project from a list of predefined Spring Boot projects known to Spring CLI (`boot add` CLI command). The command is available via: +- Right-click menu item on `pom.xml`: **Add to Boot Project** +- Command Palette: **Spring CLI: Add to Boot Project** + +Add functionality to a Spring Boot project from answer received from Gen AI (`ai add` CLI command). The command is available via: +- Right-click menu item on `pom.xml`: **Add from AI** +- Command Palette: **Spring CLI: Add from AI** + +Apply changes outlined in the Markdown guide file received as an answer to user query from Gen AI (`guide apply` CLI command). The command is available via: +- Right-click menu item on `README-ai-*.md`: **Apply Guide** +- Command Palette: **Spring CLI: Apply Guide** + +Add/Remove predefined projects catalog (`project-catalog add/remove` CLI commands). Available via: +- Command Palette: **Spring CLI: Add Project Catalog** +- Command Palette: **Spring CLI: Remove Project Catalog** + +Add/Remove predefined projects (`project add/remove` CLI commands). Available via: +- Command Palette: **Spring CLI: Add Project** +- Command Palette: **Spring CLI: Remove Project** + +New/Add/Remove user-defined command (`command new/add/remove` CLI commands). Commands can be created/added/removed locally to the workspace or globally. Available via: +- Command Palette: **Spring CLI: New User-Defined Command** (Create the command skeleton which then needs to be adjusted to user needs) +- Command Palette: **Spring CLI: Add User-Defined Command** (Adds a command to Spring CLI from a Git repo) +- Command Palette: **Spring CLI: Remove User-Defined Command** + +Execute user-defined command. Allows user to select user-defined command either locally in the workpsace or globally, then the sub-command and then enter the parameters for the selected command and finally execute it. Available via: +- Command Palette: **Spring CLI: Execute User-Defined Command** + +[Add-Functionality]: ./doc-images/Add-Functionality.png +[Gen-AI-markdown]: ./doc-images/AI-Markdown.png +[Preview-Changes]: ./doc-images/Preview-Changes.png + + \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-cli/doc-images/AI-Markdown.png b/vscode-extensions/vscode-spring-cli/doc-images/AI-Markdown.png new file mode 100644 index 000000000..9a8da0237 Binary files /dev/null and b/vscode-extensions/vscode-spring-cli/doc-images/AI-Markdown.png differ diff --git a/vscode-extensions/vscode-spring-cli/doc-images/Add-Functionality.png b/vscode-extensions/vscode-spring-cli/doc-images/Add-Functionality.png new file mode 100644 index 000000000..e2b78e841 Binary files /dev/null and b/vscode-extensions/vscode-spring-cli/doc-images/Add-Functionality.png differ diff --git a/vscode-extensions/vscode-spring-cli/doc-images/Preview-Changes.png b/vscode-extensions/vscode-spring-cli/doc-images/Preview-Changes.png new file mode 100644 index 000000000..f7cc220c6 Binary files /dev/null and b/vscode-extensions/vscode-spring-cli/doc-images/Preview-Changes.png differ diff --git a/vscode-extensions/vscode-spring-cli/package-lock.json b/vscode-extensions/vscode-spring-cli/package-lock.json index 0fc285f36..3356235de 100644 --- a/vscode-extensions/vscode-spring-cli/package-lock.json +++ b/vscode-extensions/vscode-spring-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-spring-cli", - "version": "1.52.0", + "version": "1.55.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-spring-cli", - "version": "1.52.0", + "version": "1.55.0", "license": "EPL-1.0", "dependencies": { "js-yaml": "^4.1.0", diff --git a/vscode-extensions/vscode-spring-cli/package.json b/vscode-extensions/vscode-spring-cli/package.json index 78d603d39..eb7c23398 100644 --- a/vscode-extensions/vscode-spring-cli/package.json +++ b/vscode-extensions/vscode-spring-cli/package.json @@ -3,7 +3,7 @@ "displayName": "Spring CLI Support", "description": "Spring CLI integrated into IDE", "icon": "spring-boot-logo.png", - "version": "1.55.0", + "version": "1.0.0", "publisher": "vmware", "repository": { "type": "git", @@ -33,13 +33,13 @@ "group": "Spring-CLI" }, { - "when": "resourceFilename =~ /README-\\S+.md/", - "command": "vscode-spring-cli.guide.apply", + "when": "resourceFilename == pom.xml", + "command": "vscode-spring-cli.ai.add", "group": "Spring-CLI" }, { "when": "resourceFilename =~ /README-\\S+.md/", - "command": "vscode-spring-cli.guide.run", + "command": "vscode-spring-cli.guide.apply", "group": "Spring-CLI" } ], @@ -50,13 +50,13 @@ "group": "Spring-CLI" }, { - "when": "resourceFilename =~ /README-\\S+.md/", - "command": "vscode-spring-cli.guide.apply", + "when": "resourceFilename == pom.xml", + "command": "vscode-spring-cli.ai.add", "group": "Spring-CLI" }, { "when": "resourceFilename =~ /README-\\S+.md/", - "command": "vscode-spring-cli.guide.run", + "command": "vscode-spring-cli.guide.apply", "group": "Spring-CLI" } ] @@ -94,22 +94,22 @@ }, { "command": "vscode-spring-cli.command.add", - "title": "Add User Defined Command", + "title": "Add User-Defined Command", "category": "Spring CLI" }, { "command": "vscode-spring-cli.command.remove", - "title": "Remove User Defined Command", + "title": "Remove User-Defined Command", "category": "Spring CLI" }, { "command": "vscode-spring-cli.command.new", - "title": "New User Defined Command", + "title": "New User-Defined Command", "category": "Spring CLI" }, { "command": "vscode-spring-cli.command.execute", - "title": "Execute User Defined Command", + "title": "Execute User-Defined Command", "category": "Spring CLI" }, { @@ -121,11 +121,6 @@ "command": "vscode-spring-cli.guide.apply", "title": "Apply Guide", "category": "Spring CLI" - }, - { - "command": "vscode-spring-cli.guide.run", - "title": "Run Guide", - "category": "Spring CLI" } ], "configuration": { diff --git a/vscode-extensions/vscode-spring-cli/src/ai.ts b/vscode-extensions/vscode-spring-cli/src/ai.ts index e76f23c6e..79761fdb1 100644 --- a/vscode-extensions/vscode-spring-cli/src/ai.ts +++ b/vscode-extensions/vscode-spring-cli/src/ai.ts @@ -2,19 +2,33 @@ import { Uri, commands, window } from "vscode"; import { CLI } from "./extension"; import { enterText, getTargetPomXml } from "./utils"; import path from "path"; -import { handleGuideApply } from "./guide"; +import { handleGuideApplyWorkspaceEdit } from "./guide"; +import { CANCELLED } from "./cli"; export async function handleAiAdd(pom: Uri) { - if (!pom) { - pom = await getTargetPomXml(); + let question: string; + try { + if (!pom) { + pom = await getTargetPomXml(); + } + question = await enterText({ + title: "Question", + prompt: "Enter question to LLM", + }); + } catch (error) { + // ignore: cancellation via UI } - const question = await enterText({ - title: "Question", - prompt: "Enter question to LLM", - }); - const uri = await CLI.aiAdd(question, path.dirname(pom.fsPath)); - await commands.executeCommand("markdown.showPreview", uri); - if ("Yes" === await window.showInformationMessage(`Apply guide '${path.basename(uri.fsPath)}' to the project?`, "Yes", "No")) { - handleGuideApply(uri); + try { + if (question.trim().length > 0) { + const uri = await CLI.aiAdd(question, path.dirname(pom.fsPath)); + await commands.executeCommand("markdown.showPreview", uri); + if ("Yes" === await window.showInformationMessage(`Apply guide '${path.basename(uri.fsPath)}' to the project?`, "Yes", "No")) { + handleGuideApplyWorkspaceEdit(uri); + } + } + } catch (error) { + if (error !== CANCELLED) { + window.showErrorMessage(error); + } } } diff --git a/vscode-extensions/vscode-spring-cli/src/cli.ts b/vscode-extensions/vscode-spring-cli/src/cli.ts index 256dc7ead..cd7f8e09c 100644 --- a/vscode-extensions/vscode-spring-cli/src/cli.ts +++ b/vscode-extensions/vscode-spring-cli/src/cli.ts @@ -10,6 +10,8 @@ import { WorkspaceEdit } from "vscode-languageclient"; export const SPRING_CLI_TASK_TYPE = 'spring-cli'; +export const CANCELLED = "Cancelled"; + export class Cli { get executable(): string { @@ -46,7 +48,7 @@ export class Cli { "--file", uri.fsPath ]; - return this.fetchJson("Running guide", uri.fsPath, args, cwd || path.dirname(uri.fsPath)); + return this.fetchJson("Running guide", uri.fsPath, args, cwd || path.dirname(uri.fsPath), true); } aiAdd(question: string, cwd: string): Thenable { @@ -70,23 +72,31 @@ export class Cli { return new Promise(async (resolve, reject) => { if (cancellation.isCancellationRequested) { - reject("Cancelled"); + reject(CANCELLED); } const processOpts = { cwd: cwd || getWorkspaceRoot()?.fsPath || homedir() }; const process = this.executable.endsWith(".jar") ? await cp.exec(`java -jar ${this.executable} ${args.join(" ")}`, processOpts) : await cp.exec(`${this.executable} ${args.join(" ")}`, processOpts); cancellation.onCancellationRequested(() => process.kill()); const errorMessageChunks = []; let guideFileName; + let errorMessageInStdOut; process.stdout.on("data", s => { const res = /README-\S+.md/.exec(s); - if (res.length) { + if (res && res.length) { guideFileName = res[0].trim(); + } else { + errorMessageInStdOut = s; } }); process.stderr.on("data", s => errorMessageChunks.push(s)) process.on("exit", (code) => { if (code) { - reject(`Failed to get response from LLM. ${errorMessageChunks.join()}`); + if (cancellation.isCancellationRequested) { + reject(CANCELLED); + } else { + const errorMessage = (errorMessageChunks.length == 0 ? errorMessageInStdOut : errorMessageChunks.join()) || ""; + reject(`Failed to get response from LLM. ${errorMessage}`); + } } else { resolve(Uri.file(path.join(cwd, guideFileName))); } @@ -159,12 +169,20 @@ export class Cli { }); } - commandNew(cwd: string) { + commandNew(cwd: string, commandName?: string, subCommandName?: string) { const args = [ "command", "new", ]; - return this.exec("Remove Command", undefined, args, cwd); + if (commandName) { + args.push("--command-name") + args.push(commandName); + } + if (subCommandName) { + args.push("--sub-command-name") + args.push(subCommandName); + } + return this.exec("New Command", undefined, args, cwd); } commandExecute(metadata: CommandExecuteMetadata, cwd: string) { @@ -309,7 +327,7 @@ export class Cli { }); } - private async fetchJson(title: string, message: string, args: string[], cwd?: string) : Promise { + private async fetchJson(title: string, message: string, args: string[], cwd?: string, omitJsonParam?: boolean) : Promise { return window.withProgress({ location: ProgressLocation.Window, @@ -323,16 +341,20 @@ export class Cli { return new Promise(async (resolve, reject) => { if (cancellation.isCancellationRequested) { - reject("Cancelled"); + reject(CANCELLED); } const processOpts = { cwd: cwd || getWorkspaceRoot()?.fsPath || homedir() }; - const process = this.executable.endsWith(".jar") ? await cp.exec(`java -jar ${this.executable} ${args.join(" ")}`, processOpts) : await cp.exec(`${this.executable} ${args.join(" ")} --json`, processOpts); + const process = this.executable.endsWith(".jar") ? await cp.exec(`java -jar ${this.executable} ${args.join(" ")}`, processOpts) : await cp.exec(`${this.executable} ${args.join(" ")} ${omitJsonParam ? "" : "--json"}`, processOpts); cancellation.onCancellationRequested(() => process.kill()); const dataChunks: string[] = []; process.stdout.on("data", s => dataChunks.push(s)); process.on("exit", (code) => { if (code) { - reject(`Failed to fetch data: ${dataChunks.join()}`); + if (cancellation.isCancellationRequested) { + reject(CANCELLED); + } else { + reject(`Failed to fetch data: ${dataChunks.join()}`); + } } else { try { resolve(JSON.parse(dataChunks.join()) as T); diff --git a/vscode-extensions/vscode-spring-cli/src/command.ts b/vscode-extensions/vscode-spring-cli/src/command.ts index fa036c627..fe86b7faf 100644 --- a/vscode-extensions/vscode-spring-cli/src/command.ts +++ b/vscode-extensions/vscode-spring-cli/src/command.ts @@ -3,6 +3,7 @@ import { enterText } from "./utils"; import { CLI } from "./extension"; import { homedir } from "os"; import { CommandInfo } from "./cli-types"; +import { CANCELLED } from "./cli"; interface SubCommandQuickPickItem extends QuickPickItem { info?: CommandInfo; @@ -50,22 +51,50 @@ export async function handleCommandAdd(uri?: Uri) { } export async function handleCommandRemove(uri?: Uri) { - // Enter CWD for CLI (global vs workspace folder local command) - const cwd = await enterCwd(uri); - // Select the command - const command = await pickCommand(cwd); - // Select the subcommand - const subcommand = (await pickSubCommand(cwd, command))?.label; - return CLI.commandRemove({ - command, - subcommand, - cwd - }); + try { + // Enter CWD for CLI (global vs workspace folder local command) + const cwd = await enterCwd(uri); + // Select the command + const command = await pickCommand(cwd); + if (!command) { + return; + } + // Select the subcommand + const subcommand = (await pickSubCommand(cwd, command))?.label; + if (!subcommand) { + return; + } + return CLI.commandRemove({ + command, + subcommand, + cwd + }); + } catch (error) { + if (error !== CANCELLED) { + window.showErrorMessage(error); + } + } } export async function handleCommandNew(uri?: Uri) { const cwd = await enterCwd(uri); - return CLI.commandNew(cwd); + const cmdName = await enterText({ + prompt: "Enter Command Name", + title: "Command Name", + defaultValue: "hello" + }); + if (!cmdName) { + return; + } + const subCmdName = await enterText({ + prompt: "Enter Sub-Command Name", + title: "Sub-Command Name", + defaultValue: "new" + }); + if (!subCmdName) { + return; + } + return CLI.commandNew(cwd, cmdName, subCmdName); } export async function handleCommandExecute(uri?: Uri) { @@ -73,39 +102,51 @@ export async function handleCommandExecute(uri?: Uri) { const cwd = await enterCwd(uri); // Select the command const command = await pickCommand(cwd); + if (!command) { + return; + } // Select the subcommand const subcommand = (await pickSubCommand(cwd, command)); + if (!subcommand) { + return; + } const params = {}; - if (Array.isArray(subcommand?.info?.options)) { - for (const o of subcommand.info.options) { - let value; - if (o.choices) { - const quickPickItems = Object.keys(o.choices).map(s => ({ - label: s, - value: o.choices[s] - }) as ChoiceQuickPickItem); - value = (await window.showQuickPick(quickPickItems, { canPickMany: false, ignoreFocusOut: true })).value; - } else { - value = await enterText({ - title: o.paramLabel || o.name, - defaultValue: o.defaultValue, - placeholder: o.defaultValue, - prompt: `Enter ${o.paramLabel || o.name}${o.description ? " - " : ""}${o.description}` - }); - } - if (value) { - params[o.name] = value; - } - } + try { + if (Array.isArray(subcommand?.info?.options)) { + for (const o of subcommand.info.options) { + let value; + if (o.choices) { + const quickPickItems = Object.keys(o.choices).map(s => ({ + label: s, + value: o.choices[s] + }) as ChoiceQuickPickItem); + value = (await window.showQuickPick(quickPickItems, { canPickMany: false, ignoreFocusOut: true })).value; + } else { + value = await enterText({ + title: o.paramLabel || o.name, + defaultValue: o.defaultValue, + placeholder: o.defaultValue, + prompt: `Enter ${o.paramLabel || o.name}${o.description ? " - " : ""}${o.description}` + }); + } + if (value) { + params[o.name] = value; + } + } + } + + return CLI.commandExecute({ + command, + subcommand: subcommand.label, + params + }, cwd); + } catch (error) { + if (error !== CANCELLED) { + window.showErrorMessage(error); + } } - - return CLI.commandExecute({ - command, - subcommand: subcommand.label, - params - }, cwd); } function mapFolderToQuickPickItem(folder: WorkspaceFolder): QuickPickItem { diff --git a/vscode-extensions/vscode-spring-cli/src/extension.ts b/vscode-extensions/vscode-spring-cli/src/extension.ts index 890bad08f..d71c848b3 100644 --- a/vscode-extensions/vscode-spring-cli/src/extension.ts +++ b/vscode-extensions/vscode-spring-cli/src/extension.ts @@ -6,7 +6,7 @@ import { handleProjectAdd, handleProjectRemove } from "./project"; import { handleCommandAdd, handleCommandExecute, handleCommandNew, handleCommandRemove } from "./command"; import { ExtensionContext, commands, tasks } from "vscode"; import { handleAiAdd } from "./ai"; -import { handleGuideApply, handleGuideRun } from "./guide"; +import { handleGuideApplyWorkspaceEdit } from "./guide"; export const CLI = new Cli(); @@ -29,8 +29,7 @@ export async function activate(context: ExtensionContext): Promise { commands.registerCommand('vscode-spring-cli.ai.add', handleAiAdd), - commands.registerCommand('vscode-spring-cli.guide.apply', handleGuideApply), - commands.registerCommand('vscode-spring-cli.guide.run', handleGuideRun), + commands.registerCommand('vscode-spring-cli.guide.apply', handleGuideApplyWorkspaceEdit), tasks.registerTaskProvider(SPRING_CLI_TASK_TYPE, new CliTaskProvider(CLI)) ); diff --git a/vscode-extensions/vscode-spring-cli/src/guide.ts b/vscode-extensions/vscode-spring-cli/src/guide.ts index f38babfd5..b80b2f02b 100644 --- a/vscode-extensions/vscode-spring-cli/src/guide.ts +++ b/vscode-extensions/vscode-spring-cli/src/guide.ts @@ -3,6 +3,10 @@ import { getTargetGuideMardown } from "./utils"; import { CLI } from "./extension"; import { createConverter } from "vscode-languageclient/lib/common/protocolConverter"; import fs from "fs"; +import { CANCELLED } from "./cli"; + +const APPLY = "Apply"; +const PREVIEW = "Preview" const CONVERTER = createConverter(undefined, true, true); export async function handleGuideApply(uri: Uri) { @@ -12,23 +16,41 @@ export async function handleGuideApply(uri: Uri) { return CLI.guideApply(uri); } -export async function handleGuideRun(uri: Uri) { - if (!uri) { - uri = await getTargetGuideMardown(); - } - const lspEdit = await CLI.guideLspEdit(uri); - const workspaceEdit = await CONVERTER.asWorkspaceEdit(lspEdit); - - // This is some sort of workaround for undo - // If editors for existing doc not opened then undo isn't properly working - await Promise.all(workspaceEdit.entries().map(async ([uri, edits]) => { - if (fs.existsSync(uri.fsPath)) { - const doc = await workspace.openTextDocument(uri.fsPath); - await window.showTextDocument(doc); +export async function handleGuideApplyWorkspaceEdit(uri: Uri) { + try { + if (!uri) { + uri = await getTargetGuideMardown(); } - })); + const lspEdit = await CLI.guideLspEdit(uri); + + if (lspEdit.changeAnnotations && Object.keys(lspEdit.changeAnnotations).length > 0) { + const answer = await window.showInformationMessage("Do you want to apply changes right away or preview before applying?", APPLY, PREVIEW); + if (!answer) { + return; + } + const preview = answer === PREVIEW; + Object.keys(lspEdit.changeAnnotations).forEach(changeId => { + lspEdit.changeAnnotations[changeId].needsConfirmation = preview; + }); + } + const workspaceEdit = await CONVERTER.asWorkspaceEdit(lspEdit); - return await workspace.applyEdit(workspaceEdit, { - isRefactoring: true - }); + // This is some sort of workaround for undo + // If editors for existing doc not opened then undo isn't properly working + await Promise.all(workspaceEdit.entries().map(async ([uri, edits]) => { + if (fs.existsSync(uri.fsPath)) { + const doc = await workspace.openTextDocument(uri.fsPath); + await window.showTextDocument(doc); + } + })); + + return await workspace.applyEdit(workspaceEdit, { + isRefactoring: true + }); + + } catch (error) { + if (error !== CANCELLED) { + window.showErrorMessage(error); + } + } } \ No newline at end of file