Preface
This idea started a few weeks after I switched from webstorm to vscode, and this past weekend I thought, why does the Jetbrains IDE have a project creation bootstrap, but vscode doesn't support it, and you can only use the command line tool? With this in mind, I decided to implement the equivalent plugin in vscode myself.
I have also developed a plugin for webstorm to generate vite projects Vite Integrated
The final result
Ideas
The basic idea is to create a project by rendering the configuration via vscode webview and then stitching the npx command in the main thread to execute it. Specifically, from the user's point of view, there are several steps
- open the project creation panel via the command or the menu on the ui
- select the generator to use
- select the location of the generated project
- select or enter some configuration required by the generator
- Create the project
- if it is a subdirectory of the current project, open package.json in the directory, otherwise open a new window
Some problems encountered
- How to use react to develop the view layer
- How to keep the webview interface style consistent with vscode
- How to communicate with the main thread for the rendering layer
- Using npm create or npx
- How to persist state
How to develop a view layer using react
I initially wanted to deploy the webview view layer as a standalone on github pages, and the vscode plugin part only contains bundles. vscode requires html strings as the entry point by default, which made things a bit complicated, but luckily I found the official example project hello-world-vite, which makes integrating our familiar technology stack a bit easier, and the project is modified from this template.
How to keep the webview interface style consistent with vscode
This problem was a bit complicated earlier, but with the release of the official vscode ui library vscode-webview-ui-toolkit, and the recent release of the default integration with react, the problem has become much simpler -- not perfect, of course.
The official diagram
It provides some basic components
badge
button
checkbox
data-grid
divider
dropdown
link
option
panels
progress-ring
radio
radio-group
tag
text-area
text-field
However, I still found some problems when using it in practice, mainly including
- The type definition of components is not accurate, resulting in a serious need to rely on storybook's documentation and dream back to JavaScript
- Some components are not intuitive, for example,
panels/select/text-field
is a little different from the general practice,panels
tab/panel has to be written twice,select
does not separatevalue/label
,text-field
uses - The ui library doesn't support tree shaking, resulting in a relatively large bundle and a project footprint of 439.98kb/74.79%
The dependency tree is really terrible, that's why I didn't replace react => preact
@vscode/codicons 0.0.29
@vscode/webview-ui-toolkit 0.9.3
├── @microsoft/fast-element 1.8.0
├─┬ @microsoft/fast-foundation 2.37.2
│ ├── @microsoft/fast-element 1.8.0
│ ├── @microsoft/fast-web-utilities 5.1.0
│ ├── tabbable 5.2.1
│ └── tslib 1.14.1
├─┬ @microsoft/fast-react-wrapper 0.1.43
│ ├── @microsoft/fast-element 1.8.0
│ ├─┬ @microsoft/fast-foundation 2.37.2
│ │ ├── @microsoft/fast-element 1.8.0
│ │ ├── @microsoft/fast-web-utilities 5.1.0
│ │ ├── tabbable 5.2.1
│ │ └── tslib 1.14.1
│ └── react 17.0.2 peer
└── react 17.0.2 peer
How to implement the rendering layer to communicate with the main thread
Since the core is implemented through postMessage/onmessage
, it is very similar to web worker or iframe communication. Since the code is relatively simple, I did not encapsulate the complex communication logic.
- the basic data structure of communication is defined
- use map in the main thread part to save the function exposed by
channel => handle
to the rendering thread, execute the corresponding method after receiving the ready-made message from the rendering and throw it to the rendering thread via postMessage - simply wrap it in the rendering thread so that the call is made by passing
channel + data
to call the method exposed in the main thread and get the result asynchronously
Key Code
Main thread
const map = new Map<string, (. . args: any[]) => any>()
map.set('hello', (name: string) => `hello ${name}`)
webview.onDidReceiveMessage(
async (message: any) => {
const { command, data = [], callback } = message
if (!map.has(command)) {
throw new Error(`Command not found ${command}`)
}
const res = await map.get(command)! (... .data)
if (callback) {
console.log('callback: ', callback)
this._panel.webview.postMessage({
command: callback,
data: [res],
})
}
},
undefined,
this._disposables,
)
Rendering threads
import type { WebviewApi } from 'vscode-webview'
class VSCodeAPIWrapper {
private readonly vsCodeApi: WebviewApi<unknown> | undefined
constructor() {
if (typeof acquireVsCodeApi === 'function') {
this.vsCodeApi = acquireVsCodeApi()
}
}
public postMessage(message: unknown) {
if (this.vsCodeApi) {
this.vsCodeApi.postMessage(message)
} else {
console.log(message)
}
}
async invoke(options: {
command: string
default?: any
args?: any[]
}): Promise<any> {
return await new Promise<string>((resolve) => {
if (typeof acquireVsCodeApi! == 'function') {
resolve(options.default)
return
}
const id = Date.now() + '_' + Math.random()
const listener = (message: MessageEvent) => {
const data = message.data
if (data.command === id) {
resolve(data.data[0])
window.removeEventListener('message', listener)
}
}
window.addEventListener('message', listener)
vscode.postMessage({
command: options.command,
data: options.args,
callback: id,
})
})
}
}
export const vscode = new VSCodeAPIWrapper()
console.log(await vscode.invoke({ command: 'world' })) // hello world
Use npm create or npx
For example, the command to create a react project using vite is
npx --yes --package create-vite create-vite path --template=react-ts
The command to create a project using create-react-app is
npx --yes --package create-react-app create-react-app path --template=typescript
Some people may ask: "Why don't you use a command like npm create
to create a project?"
At first glance, it seems that the create
command is already supported by the major package managers npm/yarn/pnpm
, but it has a few limitations
- npm package names are restricted in that the part that does not contain an organization name must start with
create-
, but in fact not all cli's start withcreate-
, e.g.@angular/cli
-
pnpm create
is not equivalent to npm and needs to be adapted to each package manager separately - webstorm still uses npx for project creation, which influenced my choice
So it's only natural to choose npx, and we may support global configuration using pnpx
in the future.
How to persist state
vscode actually provides an official solution to implement WebviewPanelSerializer
that allows acquireVsCodeApi()
's getState/setState
to be persisted, but it has some problems that caused me not to use it in the end.
- the state is restored only when the webview is rendered for the first time, but the changes are not persisted until vscode is closed, i.e. if you need to create two projects, the first time you create some configuration items, but the second time the configuration items are not cached, and they only take effect when vscode is reopened, which is really strange (or maybe I'm using the wrong one)
- the interface does not support kv storage, it only supports getting the full state or setting the full state
So I choose to use context.globalState
to persist data
main thread
// Other code....
map.set('getState', (key: string) => this.globalState.get(key))
map.set('setState', (key: string, value: any) =>
this.globalState.update(key, value),
)
Rendering threads
import type { WebviewApi } from 'vscode-webview'
class VSCodeAPIWrapper {
// Other code...
async getState(key: string): Promise<unknown | undefined> {
if (this.vsCodeApi) {
return await this.invoke({ command: 'getState', args: [key] })
} else {
const state = localStorage.getItem(key)
return state ? JSON.parse(state) : undefined
}
}
async setState<T extends unknown | undefined>(
key: string,
newState: T,
): Promise<void> {
if (this.vsCodeApi) {
return await this.invoke({ command: 'setState', args: [key, newState] })
} else {
localStorage.setItem(key, JSON.stringify(newState))
}
}
}
export const vscode = new VSCodeAPIWrapper()
const store = (await vscode.getState(props.id)) as { location: string }
It is now possible to support that selected generators and configuration items will still be cached the next time they are used, which is really handy when you need to create projects with the same generator, especially if there are more generator options.
Conclusion
vscode is still not as good as webstorm in some ways, but its popularity is simply unstoppable, and many front-end projects even officially support only vscode editors, which makes it a no-option at some point (e.g. vue3, nx). So instead of complaining about it, you should try to make it better.
Top comments (1)
I can't seem to get gifs to work, can anyone help me?