Polish extension

This commit is contained in:
aboyko
2024-05-17 15:12:03 -04:00
parent fe953bde5b
commit b50fef99ba
11 changed files with 250 additions and 98 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -1,12 +1,12 @@
{ {
"name": "vscode-spring-cli", "name": "vscode-spring-cli",
"version": "1.52.0", "version": "1.55.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vscode-spring-cli", "name": "vscode-spring-cli",
"version": "1.52.0", "version": "1.55.0",
"license": "EPL-1.0", "license": "EPL-1.0",
"dependencies": { "dependencies": {
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

View File

@@ -3,7 +3,7 @@
"displayName": "Spring CLI Support", "displayName": "Spring CLI Support",
"description": "Spring CLI integrated into IDE", "description": "Spring CLI integrated into IDE",
"icon": "spring-boot-logo.png", "icon": "spring-boot-logo.png",
"version": "1.55.0", "version": "1.0.0",
"publisher": "vmware", "publisher": "vmware",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,13 +33,13 @@
"group": "Spring-CLI" "group": "Spring-CLI"
}, },
{ {
"when": "resourceFilename =~ /README-\\S+.md/", "when": "resourceFilename == pom.xml",
"command": "vscode-spring-cli.guide.apply", "command": "vscode-spring-cli.ai.add",
"group": "Spring-CLI" "group": "Spring-CLI"
}, },
{ {
"when": "resourceFilename =~ /README-\\S+.md/", "when": "resourceFilename =~ /README-\\S+.md/",
"command": "vscode-spring-cli.guide.run", "command": "vscode-spring-cli.guide.apply",
"group": "Spring-CLI" "group": "Spring-CLI"
} }
], ],
@@ -50,13 +50,13 @@
"group": "Spring-CLI" "group": "Spring-CLI"
}, },
{ {
"when": "resourceFilename =~ /README-\\S+.md/", "when": "resourceFilename == pom.xml",
"command": "vscode-spring-cli.guide.apply", "command": "vscode-spring-cli.ai.add",
"group": "Spring-CLI" "group": "Spring-CLI"
}, },
{ {
"when": "resourceFilename =~ /README-\\S+.md/", "when": "resourceFilename =~ /README-\\S+.md/",
"command": "vscode-spring-cli.guide.run", "command": "vscode-spring-cli.guide.apply",
"group": "Spring-CLI" "group": "Spring-CLI"
} }
] ]
@@ -94,22 +94,22 @@
}, },
{ {
"command": "vscode-spring-cli.command.add", "command": "vscode-spring-cli.command.add",
"title": "Add User Defined Command", "title": "Add User-Defined Command",
"category": "Spring CLI" "category": "Spring CLI"
}, },
{ {
"command": "vscode-spring-cli.command.remove", "command": "vscode-spring-cli.command.remove",
"title": "Remove User Defined Command", "title": "Remove User-Defined Command",
"category": "Spring CLI" "category": "Spring CLI"
}, },
{ {
"command": "vscode-spring-cli.command.new", "command": "vscode-spring-cli.command.new",
"title": "New User Defined Command", "title": "New User-Defined Command",
"category": "Spring CLI" "category": "Spring CLI"
}, },
{ {
"command": "vscode-spring-cli.command.execute", "command": "vscode-spring-cli.command.execute",
"title": "Execute User Defined Command", "title": "Execute User-Defined Command",
"category": "Spring CLI" "category": "Spring CLI"
}, },
{ {
@@ -121,11 +121,6 @@
"command": "vscode-spring-cli.guide.apply", "command": "vscode-spring-cli.guide.apply",
"title": "Apply Guide", "title": "Apply Guide",
"category": "Spring CLI" "category": "Spring CLI"
},
{
"command": "vscode-spring-cli.guide.run",
"title": "Run Guide",
"category": "Spring CLI"
} }
], ],
"configuration": { "configuration": {

View File

@@ -2,19 +2,33 @@ import { Uri, commands, window } from "vscode";
import { CLI } from "./extension"; import { CLI } from "./extension";
import { enterText, getTargetPomXml } from "./utils"; import { enterText, getTargetPomXml } from "./utils";
import path from "path"; import path from "path";
import { handleGuideApply } from "./guide"; import { handleGuideApplyWorkspaceEdit } from "./guide";
import { CANCELLED } from "./cli";
export async function handleAiAdd(pom: Uri) { export async function handleAiAdd(pom: Uri) {
if (!pom) { let question: string;
pom = await getTargetPomXml(); 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({ try {
title: "Question", if (question.trim().length > 0) {
prompt: "Enter question to LLM", const uri = await CLI.aiAdd(question, path.dirname(pom.fsPath));
}); await commands.executeCommand("markdown.showPreview", uri);
const uri = await CLI.aiAdd(question, path.dirname(pom.fsPath)); if ("Yes" === await window.showInformationMessage(`Apply guide '${path.basename(uri.fsPath)}' to the project?`, "Yes", "No")) {
await commands.executeCommand("markdown.showPreview", uri); handleGuideApplyWorkspaceEdit(uri);
if ("Yes" === await window.showInformationMessage(`Apply guide '${path.basename(uri.fsPath)}' to the project?`, "Yes", "No")) { }
handleGuideApply(uri); }
} catch (error) {
if (error !== CANCELLED) {
window.showErrorMessage(error);
}
} }
} }

View File

@@ -10,6 +10,8 @@ import { WorkspaceEdit } from "vscode-languageclient";
export const SPRING_CLI_TASK_TYPE = 'spring-cli'; export const SPRING_CLI_TASK_TYPE = 'spring-cli';
export const CANCELLED = "Cancelled";
export class Cli { export class Cli {
get executable(): string { get executable(): string {
@@ -46,7 +48,7 @@ export class Cli {
"--file", "--file",
uri.fsPath 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<Uri> { aiAdd(question: string, cwd: string): Thenable<Uri> {
@@ -70,23 +72,31 @@ export class Cli {
return new Promise<Uri>(async (resolve, reject) => { return new Promise<Uri>(async (resolve, reject) => {
if (cancellation.isCancellationRequested) { if (cancellation.isCancellationRequested) {
reject("Cancelled"); reject(CANCELLED);
} }
const processOpts = { cwd: cwd || getWorkspaceRoot()?.fsPath || homedir() }; 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); 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()); cancellation.onCancellationRequested(() => process.kill());
const errorMessageChunks = []; const errorMessageChunks = [];
let guideFileName; let guideFileName;
let errorMessageInStdOut;
process.stdout.on("data", s => { process.stdout.on("data", s => {
const res = /README-\S+.md/.exec(s); const res = /README-\S+.md/.exec(s);
if (res.length) { if (res && res.length) {
guideFileName = res[0].trim(); guideFileName = res[0].trim();
} else {
errorMessageInStdOut = s;
} }
}); });
process.stderr.on("data", s => errorMessageChunks.push(s)) process.stderr.on("data", s => errorMessageChunks.push(s))
process.on("exit", (code) => { process.on("exit", (code) => {
if (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 { } else {
resolve(Uri.file(path.join(cwd, guideFileName))); 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 = [ const args = [
"command", "command",
"new", "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) { commandExecute(metadata: CommandExecuteMetadata, cwd: string) {
@@ -309,7 +327,7 @@ export class Cli {
}); });
} }
private async fetchJson<T>(title: string, message: string, args: string[], cwd?: string) : Promise<T> { private async fetchJson<T>(title: string, message: string, args: string[], cwd?: string, omitJsonParam?: boolean) : Promise<T> {
return window.withProgress({ return window.withProgress({
location: ProgressLocation.Window, location: ProgressLocation.Window,
@@ -323,16 +341,20 @@ export class Cli {
return new Promise<T>(async (resolve, reject) => { return new Promise<T>(async (resolve, reject) => {
if (cancellation.isCancellationRequested) { if (cancellation.isCancellationRequested) {
reject("Cancelled"); reject(CANCELLED);
} }
const processOpts = { cwd: cwd || getWorkspaceRoot()?.fsPath || homedir() }; 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()); cancellation.onCancellationRequested(() => process.kill());
const dataChunks: string[] = []; const dataChunks: string[] = [];
process.stdout.on("data", s => dataChunks.push(s)); process.stdout.on("data", s => dataChunks.push(s));
process.on("exit", (code) => { process.on("exit", (code) => {
if (code) { if (code) {
reject(`Failed to fetch data: ${dataChunks.join()}`); if (cancellation.isCancellationRequested) {
reject(CANCELLED);
} else {
reject(`Failed to fetch data: ${dataChunks.join()}`);
}
} else { } else {
try { try {
resolve(JSON.parse(dataChunks.join()) as T); resolve(JSON.parse(dataChunks.join()) as T);

View File

@@ -3,6 +3,7 @@ import { enterText } from "./utils";
import { CLI } from "./extension"; import { CLI } from "./extension";
import { homedir } from "os"; import { homedir } from "os";
import { CommandInfo } from "./cli-types"; import { CommandInfo } from "./cli-types";
import { CANCELLED } from "./cli";
interface SubCommandQuickPickItem extends QuickPickItem { interface SubCommandQuickPickItem extends QuickPickItem {
info?: CommandInfo; info?: CommandInfo;
@@ -50,22 +51,50 @@ export async function handleCommandAdd(uri?: Uri) {
} }
export async function handleCommandRemove(uri?: Uri) { export async function handleCommandRemove(uri?: Uri) {
// Enter CWD for CLI (global vs workspace folder local command) try {
const cwd = await enterCwd(uri); // Enter CWD for CLI (global vs workspace folder local command)
// Select the command const cwd = await enterCwd(uri);
const command = await pickCommand(cwd); // Select the command
// Select the subcommand const command = await pickCommand(cwd);
const subcommand = (await pickSubCommand(cwd, command))?.label; if (!command) {
return CLI.commandRemove({ return;
command, }
subcommand, // Select the subcommand
cwd 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) { export async function handleCommandNew(uri?: Uri) {
const cwd = await enterCwd(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) { export async function handleCommandExecute(uri?: Uri) {
@@ -73,39 +102,51 @@ export async function handleCommandExecute(uri?: Uri) {
const cwd = await enterCwd(uri); const cwd = await enterCwd(uri);
// Select the command // Select the command
const command = await pickCommand(cwd); const command = await pickCommand(cwd);
if (!command) {
return;
}
// Select the subcommand // Select the subcommand
const subcommand = (await pickSubCommand(cwd, command)); const subcommand = (await pickSubCommand(cwd, command));
if (!subcommand) {
return;
}
const params = {}; const params = {};
if (Array.isArray(subcommand?.info?.options)) { try {
for (const o of subcommand.info.options) { if (Array.isArray(subcommand?.info?.options)) {
let value; for (const o of subcommand.info.options) {
if (o.choices) { let value;
const quickPickItems = Object.keys(o.choices).map(s => ({ if (o.choices) {
label: s, const quickPickItems = Object.keys(o.choices).map(s => ({
value: o.choices[s] label: s,
}) as ChoiceQuickPickItem); value: o.choices[s]
value = (await window.showQuickPick(quickPickItems, { canPickMany: false, ignoreFocusOut: true })).value; }) as ChoiceQuickPickItem);
} else { value = (await window.showQuickPick(quickPickItems, { canPickMany: false, ignoreFocusOut: true })).value;
value = await enterText({ } else {
title: o.paramLabel || o.name, value = await enterText({
defaultValue: o.defaultValue, title: o.paramLabel || o.name,
placeholder: o.defaultValue, defaultValue: o.defaultValue,
prompt: `Enter ${o.paramLabel || o.name}${o.description ? " - " : ""}${o.description}` placeholder: o.defaultValue,
}); prompt: `Enter ${o.paramLabel || o.name}${o.description ? " - " : ""}${o.description}`
} });
if (value) { }
params[o.name] = value; 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 { function mapFolderToQuickPickItem(folder: WorkspaceFolder): QuickPickItem {

View File

@@ -6,7 +6,7 @@ import { handleProjectAdd, handleProjectRemove } from "./project";
import { handleCommandAdd, handleCommandExecute, handleCommandNew, handleCommandRemove } from "./command"; import { handleCommandAdd, handleCommandExecute, handleCommandNew, handleCommandRemove } from "./command";
import { ExtensionContext, commands, tasks } from "vscode"; import { ExtensionContext, commands, tasks } from "vscode";
import { handleAiAdd } from "./ai"; import { handleAiAdd } from "./ai";
import { handleGuideApply, handleGuideRun } from "./guide"; import { handleGuideApplyWorkspaceEdit } from "./guide";
export const CLI = new Cli(); export const CLI = new Cli();
@@ -29,8 +29,7 @@ export async function activate(context: ExtensionContext): Promise<void> {
commands.registerCommand('vscode-spring-cli.ai.add', handleAiAdd), commands.registerCommand('vscode-spring-cli.ai.add', handleAiAdd),
commands.registerCommand('vscode-spring-cli.guide.apply', handleGuideApply), commands.registerCommand('vscode-spring-cli.guide.apply', handleGuideApplyWorkspaceEdit),
commands.registerCommand('vscode-spring-cli.guide.run', handleGuideRun),
tasks.registerTaskProvider(SPRING_CLI_TASK_TYPE, new CliTaskProvider(CLI)) tasks.registerTaskProvider(SPRING_CLI_TASK_TYPE, new CliTaskProvider(CLI))
); );

View File

@@ -3,6 +3,10 @@ import { getTargetGuideMardown } from "./utils";
import { CLI } from "./extension"; import { CLI } from "./extension";
import { createConverter } from "vscode-languageclient/lib/common/protocolConverter"; import { createConverter } from "vscode-languageclient/lib/common/protocolConverter";
import fs from "fs"; import fs from "fs";
import { CANCELLED } from "./cli";
const APPLY = "Apply";
const PREVIEW = "Preview"
const CONVERTER = createConverter(undefined, true, true); const CONVERTER = createConverter(undefined, true, true);
export async function handleGuideApply(uri: Uri) { export async function handleGuideApply(uri: Uri) {
@@ -12,23 +16,41 @@ export async function handleGuideApply(uri: Uri) {
return CLI.guideApply(uri); return CLI.guideApply(uri);
} }
export async function handleGuideRun(uri: Uri) { export async function handleGuideApplyWorkspaceEdit(uri: Uri) {
if (!uri) { try {
uri = await getTargetGuideMardown(); 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);
} }
})); 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, { // This is some sort of workaround for undo
isRefactoring: true // 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);
}
}
} }