diff --git a/README.md b/README.md index b128576..9161025 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,27 @@ You can open AsyncAPI Preview from the editor title/context menu. Automatic hot-reloading on editor save, but currently, it doesn't reload when saving referenced external files. +## Content Assistance + +### Available snippets: + +Open an empty or otherwise yaml file and start typing one of the following prefixes, you may need to press `Ctrl+space` to trigger autocompletion in some cases: + +- `add asyncapi skeleton`: Adds an asyncapi skeleton for jump starting your API editing. +- `add asyncapi subscribe to async request`: Inserts a new subscribe operation, for listening to incoming async requests/commands. +- `add asyncapi publish event operation`: Inserts a new publish operation, for producing domain events. +- `add asyncapi message`: Inserts a new message, you can choose it to be either a **Request** or an **Event**. + +Once snippets are inserted use the `` key to travel between snippet placeholders. + +## Paste as Schema + +You can also autogenerate an Schema object from a JSON example. + +Right-click inside `#/components/schemas` section and choose `AsyncAPI: Paste as Schema` from the context menu. + +![VSCode AsyncapiPreview - Content Assistance](docs/VSCode%20AsyncAPI%20Content%20Assistance-X4.gif) + ### Credits AsyncAPI Viewer utilizes the following open source projects: diff --git a/docs/VSCode AsyncAPI Content Assistance-X4.gif b/docs/VSCode AsyncAPI Content Assistance-X4.gif new file mode 100644 index 0000000..fcc19d9 Binary files /dev/null and b/docs/VSCode AsyncAPI Content Assistance-X4.gif differ diff --git a/package-lock.json b/package-lock.json index 66f9a61..6724cf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@semantic-release/github": "8.0.6", "@semantic-release/release-notes-generator": "^10.0.3", "@types/glob": "^7.2.0", + "@types/js-yaml": "^4.0.5", "@types/mocha": "^9.1.1", "@types/node": "14.x", "@types/vscode": "^1.66.0", @@ -24,6 +25,7 @@ "conventional-changelog-conventionalcommits": "^4.2.3", "copy-webpack-plugin": "^10.2.4", "eslint": "^8.14.0", + "genson-js": "0.0.8", "glob": "^8.0.1", "mocha": "^9.2.2", "semantic-release": "19.0.5", @@ -815,6 +817,12 @@ "@types/node": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", + "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -3202,6 +3210,12 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "node_modules/genson-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/genson-js/-/genson-js-0.0.8.tgz", + "integrity": "sha512-4NUusDTwF+lzYh72uKV+Uvpky9iPO+YDIMpGImA5pbHfLV9HwgRCA4hYjGu78V4J4Cx2IZRTFfRERn9aUs74mw==", + "dev": true + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10399,6 +10413,12 @@ "@types/node": "*" } }, + "@types/js-yaml": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", + "integrity": "sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==", + "dev": true + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -12202,6 +12222,12 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "genson-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/genson-js/-/genson-js-0.0.8.tgz", + "integrity": "sha512-4NUusDTwF+lzYh72uKV+Uvpky9iPO+YDIMpGImA5pbHfLV9HwgRCA4hYjGu78V4J4Cx2IZRTFfRERn9aUs74mw==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/package.json b/package.json index 7ce9c7e..ba2cbb9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,16 @@ "light": "resources/icons/open-preview_black.svg", "dark": "resources/icons/open-preview_white.svg" } + }, + { + "command": "asyncapi.paste", + "title": "AsyncAPI: Paste as Schema" + } + ], + "snippets": [ + { + "language": "yaml", + "path": "./snippets/snippets.json" } ], "menus": { @@ -63,6 +73,22 @@ { "command": "asyncapi.preview" } + ], + "editor/context": [ + { + "when": "resourceLangId == json || resourceLangId == yaml && asyncapi.isAsyncAPI", + "command": "asyncapi.paste", + "group": "9_cutcopypaste@5" + } + ], + "keybindings": [ + { + "command": "asyncapi.paste", + "key": "Ctrl+V", + "linux": "Ctrl+Shift+V", + "mac": "Cmd+V", + "when": "editorTextFocus" + } ] } }, @@ -83,6 +109,7 @@ "@semantic-release/github": "8.0.6", "@semantic-release/release-notes-generator": "^10.0.3", "@types/glob": "^7.2.0", + "@types/js-yaml": "^4.0.5", "@types/mocha": "^9.1.1", "@types/node": "14.x", "@types/vscode": "^1.66.0", @@ -93,6 +120,7 @@ "conventional-changelog-conventionalcommits": "^4.2.3", "copy-webpack-plugin": "^10.2.4", "eslint": "^8.14.0", + "genson-js": "0.0.8", "glob": "^8.0.1", "mocha": "^9.2.2", "semantic-release": "19.0.5", diff --git a/snippets/snippets.json b/snippets/snippets.json new file mode 100644 index 0000000..0d03a1b --- /dev/null +++ b/snippets/snippets.json @@ -0,0 +1,82 @@ +{ + "Add AsyncAPI Skeleton": { + "prefix": "add asyncapi skeleton", + "body": [ + "asyncapi: '2.5.0'", + "info:", + " title: ${1:Title}", + " version: '1.0.0'", + " description: |", + " Add your multiline description here.", + " contact:", + " name: ${2:Contact Name}", + " email: ${3:email@company.com}", + "", + "servers:", + " production:", + " url: server.url", + " protocol: mqtt", + " description: server description", + "", + "defaultContentType: application/json", + "", + "channels:", + " this.is.one.channel.please.rename:", + " description: A sample topic on which messages may be produced and consumed.", + "${4:# type 'add publish' or 'add subscribe' to get snippets autocomplete}", + "", + "", + "", + "components:", + " messages:", + " description: add one message and remove this line", + " # type here 'add message' to get snippets autocomplete", + "", + "", + " schemas:", + " OneEntity:", + " type: object", + "", + "" + ] + }, + "Add Subscribe to Async Request": { + "prefix": "add asyncapi subscribe to async request", + "body": [ + " subscribe:", + " summary: ${1:Entity} Async Requests", + " operationId: do${1:Entity}Request", + " tags:", + " - name: ${1:Entity}", + " message:", + " \\$ref: '#/components/messages/${1:Entity}RequestMessage'", + "" + ] + }, + "Add Publish Event Operation": { + "prefix": "add asyncapi publish event operation", + "body": [ + " publish:", + " summary: ${1:Entity} Domain Events", + " operationId: on${1:Entity}Event", + " tags:", + " - name: ${1:Entity}", + " message:", + " \\$ref: '#/components/messages/${1:Entity}EventMessage'", + "" + ] + }, + "Add AsyncAPI Message": { + "prefix": "add asyncapi message", + "body": [ + " ${1:Entity}${2|Request,Event|}Message:", + " name: ${1}${2}Message", + " title: Async ${2} for a ${1}", + " summary: Async ${2} for a ${1}", + " schemaFormat: application/vnd.aai.asyncapi;version=2.4.0", + " payload:", + " \\$ref: '#/components/schemas/${1}'", + "" + ] + } +} diff --git a/src/PreviewWebPanel.ts b/src/PreviewWebPanel.ts new file mode 100644 index 0000000..9e7ec61 --- /dev/null +++ b/src/PreviewWebPanel.ts @@ -0,0 +1,116 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export function previewAsyncAPI(context: vscode.ExtensionContext) { + return async (uri: vscode.Uri) => { + uri = uri || (await promptForAsyncapiFile()) as vscode.Uri; + if (uri) { + console.log('Opening asyncapi file', uri.fsPath); + openAsyncAPI(context, uri); + } + }; +} + +export const openAsyncapiFiles: { [id: string]: vscode.WebviewPanel } = {}; // vscode.Uri.fsPath => vscode.WebviewPanel + +export function isAsyncAPIFile(document?: vscode.TextDocument) { + if (!document) { + return false; + } + if (document.languageId === 'json') { + try { + const json = JSON.parse(document.getText()); + return json.asyncapi; + } catch (e) { + return false; + } + } + if (document.languageId === 'yml' || document.languageId === 'yaml') { + return document.getText().match('^asyncapi:') !== null; + } + return false; +} + +export function openAsyncAPI(context: vscode.ExtensionContext, uri: vscode.Uri) { + const localResourceRoots = [ + vscode.Uri.file(path.dirname(uri.fsPath)), + vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/browser/standalone'), + vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/styles'), + ]; + if (vscode.workspace.workspaceFolders) { + vscode.workspace.workspaceFolders.forEach(folder => { + localResourceRoots.push(folder.uri); + }); + } + const panel: vscode.WebviewPanel = + openAsyncapiFiles[uri.fsPath] || + vscode.window.createWebviewPanel('asyncapi-preview', '', vscode.ViewColumn.Two, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots, + }); + panel.title = path.basename(uri.fsPath); + panel.webview.html = getWebviewContent(context, panel.webview, uri); + + panel.onDidDispose(() => { + delete openAsyncapiFiles[uri.fsPath]; + }); + openAsyncapiFiles[uri.fsPath] = panel; +} + +async function promptForAsyncapiFile() { + if (isAsyncAPIFile(vscode.window.activeTextEditor?.document)) { + return vscode.window.activeTextEditor?.document.uri; + } + return await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: 'Open AsyncAPI file', + filters: { + AsyncAPI: ['yml', 'yaml', 'json'], + }, + }); +} + +function getWebviewContent(context: vscode.ExtensionContext, webview: vscode.Webview, asyncapiFile: vscode.Uri) { + const asyncapiComponentJs = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/browser/standalone/index.js') + ); + const asyncapiComponentCss = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/styles/default.min.css') + ); + const asyncapiWebviewUri = webview.asWebviewUri(asyncapiFile); + const asyncapiBasePath = asyncapiWebviewUri.toString().replace('%2B', '+'); // this is loaded by a different library so it requires unescaping the + character + const html = ` + + + + + + + +
+ + + + + + + `; + return html; +} diff --git a/src/SmartPasteCommand.ts b/src/SmartPasteCommand.ts new file mode 100644 index 0000000..8f45bd5 --- /dev/null +++ b/src/SmartPasteCommand.ts @@ -0,0 +1,58 @@ +import * as vscode from 'vscode'; +import * as yml from 'js-yaml'; +import { createSchema } from 'genson-js'; + +export async function asyncapiSmartPaste() { + let editor = vscode.window.activeTextEditor; + if(!editor) { + return; + } + let start = editor.selection.start; + let end = editor.selection.end; + let selectedText = editor.document.getText(editor.selection); + let currentLineText = editor.document.lineAt(start.line).text; + let clipboad = await vscode.env.clipboard.readText(); + + const json = parse(clipboad); + if(typeof json === 'object') { + const schema = { PastedSchema: createSchema(json) }; + replace(editor, stringify(schema, editor.document.languageId, 4), editor.selection); + return; + } + + + return vscode.commands.executeCommand('editor.action.clipboardPasteAction'); +} + +function insertNewLine(editor: vscode.TextEditor, text: string, line: number) { + editor.edit((editBuilder: vscode.TextEditorEdit) => { + editBuilder.insert(new vscode.Position(line + 1, 0), text + '\n'); + }); +} + +function replace(editor: vscode.TextEditor, text: string, selection: vscode.Selection) { + editor.edit((editBuilder: vscode.TextEditorEdit) => { + editBuilder.replace(selection, text); + editor.revealRange(new vscode.Range(selection.start, selection.start)); + }); +} + +function parse(text: string) { + try { + return JSON.parse(text); + } catch (e) { + try { + return yml.load(text); + } catch (e) { + return text; + } + } +} + +function stringify(schema: object, languageId: string, indentYml: number) { + if(languageId === 'json') { + return JSON.stringify(schema, null, 2); + } else { + return yml.dump(schema).replace(/^(?!\s*$)/gm, ' '.repeat(indentYml)); + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index ca93ad6..3aab485 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; -import * as path from 'path'; +import { isAsyncAPIFile, openAsyncAPI, openAsyncapiFiles, previewAsyncAPI } from './PreviewWebPanel'; +import { asyncapiSmartPaste } from './SmartPasteCommand'; -const openAsyncapiFiles: { [id: string]: vscode.WebviewPanel } = {}; // vscode.Uri.fsPath => vscode.WebviewPanel export function activate(context: vscode.ExtensionContext) { console.log('Congratulations, your extension "asyncapi-preview" is now active!'); @@ -30,117 +30,10 @@ export function activate(context: vscode.ExtensionContext) { } }); - let disposable = vscode.commands.registerCommand('asyncapi.preview', async (uri: vscode.Uri) => { - uri = uri || (await promptForAsyncapiFile()); - if (uri) { - console.log('Opening asyncapi file', uri.fsPath); - openAsyncAPI(context, uri); - } - }); - - context.subscriptions.push(disposable); -} - -function isAsyncAPIFile(document?: vscode.TextDocument) { - if (!document) { - return false; - } - if (document.languageId === 'json') { - try { - const json = JSON.parse(document.getText()); - return json.asyncapi; - } catch (e) { - return false; - } - } - if(document.languageId === 'yml' || document.languageId === 'yaml') { - return document.getText().match('^asyncapi:') !== null; - } - return false; -} - -function openAsyncAPI(context: vscode.ExtensionContext, uri: vscode.Uri) { - const localResourceRoots = [ - vscode.Uri.file(path.dirname(uri.fsPath)), - vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/browser/standalone'), - vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/styles'), - ]; - if (vscode.workspace.workspaceFolders) { - vscode.workspace.workspaceFolders.forEach(folder => { - localResourceRoots.push(folder.uri); - }); - } - const panel: vscode.WebviewPanel = - openAsyncapiFiles[uri.fsPath] || - vscode.window.createWebviewPanel('asyncapi-preview', '', vscode.ViewColumn.Two, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots, - }); - panel.title = path.basename(uri.fsPath); - panel.webview.html = getWebviewContent(context, panel.webview, uri); - - panel.onDidDispose(() => { - delete openAsyncapiFiles[uri.fsPath]; - }); - openAsyncapiFiles[uri.fsPath] = panel; -} - -async function promptForAsyncapiFile() { - if (isAsyncAPIFile(vscode.window.activeTextEditor?.document)) { - return vscode.window.activeTextEditor?.document.uri; - } - return await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - openLabel: 'Open AsyncAPI file', - filters: { - AsyncAPI: ['yml', 'yaml', 'json'], - }, - }); -} - -function getWebviewContent(context: vscode.ExtensionContext, webview: vscode.Webview, asyncapiFile: vscode.Uri) { - const asyncapiComponentJs = webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/browser/standalone/index.js') - ); - const asyncapiComponentCss = webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, 'dist/node_modules/@asyncapi/react-component/styles/default.min.css') - ); - const asyncapiWebviewUri = webview.asWebviewUri(asyncapiFile); - const asyncapiBasePath = asyncapiWebviewUri.toString().replace('%2B', '+'); // this is loaded by a different library so it requires unescaping the + character - const html = ` - - - - - - - -
- - + context.subscriptions.push(vscode.commands.registerCommand('asyncapi.preview', previewAsyncAPI(context))); - - - `; - return html; + context.subscriptions.push(vscode.commands.registerCommand("asyncapi.paste", asyncapiSmartPaste)); } export function deactivate() {}