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",
"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",

View File

@@ -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": {

View File

@@ -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);
}
}
}

View File

@@ -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<Uri> {
@@ -70,23 +72,31 @@ export class Cli {
return new Promise<Uri>(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<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({
location: ProgressLocation.Window,
@@ -323,16 +341,20 @@ export class Cli {
return new Promise<T>(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);

View File

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

View File

@@ -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<void> {
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))
);

View File

@@ -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);
}
}
}