ReactとTailwindを使ってVSCode向けの拡張機能が作りたい！

## ここは読み飛ばしてもOKです

どうも限界派遣SESの自称社内ニートです。
ある程度暇になると、普段自分が本当に仕事をしているのか？と不安になるため社内ニートな気がしてきています。

ひとりアドカレ7日目は前から作りたかったVSCodeの拡張機能を作ることにしました。

## VSCodeの拡張機能を作る方法

今回は以下の記事を参考にしています。

https://zenn.dev/hiro256ex/articles/20230625_getstartedvscodeextension

### ざっくりテンプレートの作成

上記の方法で`Yeoman` と `VS Code Extension Generator`のパッケージを追加して`yo code`コマンドを実行して対話形式でテンプレートを作っていきます。

```bash
$ npm install -g yo generator-code

$ yo code
```

設定に関しては以下の2点のみは固定で、あとは任意の設定をしてください。(typescriptじゃなくても良いけどjavascriptにする理由も見つからないので。)

`What type of extension do you want to create?`に対して`New Extension (TypeScript)`を指定。
`Which bundler to use?`に対して`webpack`を指定。


```bash
     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? my_extension
? What's the identifier of your extension? my-extension
? What's the description of your extension? 
? Initialize a git repository? Yes
? Which bundler to use? webpack
? Which package manager to use? npm
```

これで一度デバッグ起動すると`Hello World`コマンドが追加されていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/75f44bf8-22d2-f103-9945-12697b2fa5a8.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/0a8535c3-d6a2-f21d-1ff6-9bf11e0ec0ad.png)


### とりあえずWebViewを表示する

Web画面を表示するだけであれば、WebViewを利用することでHTMLのデータを表示することが出来ます。
テンプレートで生成された`extension.ts`を以下のように書き換える事でWebViewを表示できます。

しかし、標準的な手法ではHTMLは文字列で扱わなくてはならず、色々と扱いにくいです。

```ts: extension.ts
import * as vscode from 'vscode';
import * as childProcess from 'child_process';

export function activate(context: vscode.ExtensionContext) {

	// Destroyコマンドを登録
	const disposable = vscode.commands.registerCommand('my-extension.destroy', () => {

		// WebViewを開く
		const panel = vscode.window.createWebviewPanel(
			'destroy',
			'Destroy',
			vscode.ViewColumn.One,
			{
				enableScripts: true
			}
		);

		// WebViewに表示するHTMLを生成
		panel.webview.html = getWebviewContent();


		// WebViewからの呼び出すコマンドを定義
		panel.webview.onDidReceiveMessage(
			message => {
				switch (message.command) {
					case 'destroy':
						// consoleのコマンドを実行する
						const command = "echo 'バルス'";

						// コマンドを実行
						childProcess.exec(command, (error, stdout, stderr) => {
							if (error) {
								console.error(`exec error: ${error}`);
								return;
							}
							if (stdout) {
								console.log(`stdout: ${stdout}`);
								return;
							}
						});
						return;
				}
			},
			undefined,
			context.subscriptions
		);

	});

	context.subscriptions.push(disposable);
}

// WebViewに表示するHTMLを生成（これが文字列なのでちょっとメンドイ）
function getWebviewContent() {
	return `<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Cat Coding</title>
    </head>
    <body>
        <h1>Destroy</h1>
        <!-- このボタンからコマンドを呼び出す。 -->
        <button onclick="destroy()">Destroy</button> 

        <script>
            const vscode = acquireVsCodeApi();

            function destroy() {
                // PostMessageで対象のコマンドを指定して呼び出しを行う
                vscode.postMessage({
                    command: 'destroy' 
                });
            }
        </script>
    </body>
    </html>`;
}


export function deactivate() { }
```

コマンド名を変更しているので`package.json`の`commands`の部分を変更しておきましょう。

```diff_jsondoc: package.json
"contributes": {
 "commands": [
   {
-      "command": "my-extension.helloWorld",
-      "title": "Hello World"
+      "command": "my-extension.destroy",
+      "title": "Destroy"
   }
 ]
}
```

`Destroy`コマンドを実行した際以下のように新しいタブが追加され、Destroyボタンが押せるようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/2b139a63-b1ac-ead7-ec92-ae1e06461f74.png)

これで`sudo rm -rf --no-preserve-root /`[^1]を実行してあげればdestroy完了です。

[^1]: 破滅の呪文。本番環境では絶対に実行しないでね。

上記のようにWebViewを実装する場合、HTMLは文字列として扱わなければならず、本当に扱いにくいです。
CSSファイルもこの文字列の中に実装しなくてはならず実用的とはいえません。

そのため、ReactとTailwindcssを扱えるようにしていきましょう。

## ReactとTailwindcssを使えるようにする

Reactを使えるようにする方法は以下を参考にしています。

https://qiita.com/yakiniku0220/items/14b60a66530d70d261a5

まずは必要なパッケージをインストールしていきます。

```bash
npm install react react-dom tailwindcss postcss style-loader postcss-loader css-loader autoprefixer
```

### PostCSSの設定

普段Next.jsなどでTailwindcssを設定する際は`npx create-next-app@latest`コマンドを実行することで、自動的にインストールされるため気にしたことがなかったですが、そもそもTailwindcssはPostCSSのプラグインだそうです。

`postcss.config.js`を作成して以下を記述してください。

```js: postcss.config.js
module.exports = {
    plugins: {
        tailwindcss: {},
        autoprefixer: {},
    },
};
```


### Tailwindcssの設定

Tailwindcssを初期化していきましょう。

```bash
npx tailwindcss init
```

コマンドを実行すると`tailwind.config.js`ファイルが生成されるため、以下のように書き換えます。

`purge`を指定することで未使用のCSSを削除し軽量化をしてくれるそうです。

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  // Tailwind CSSを適用するファイルのパスを指定
  purge: ['./src/**/*.{js,jsx,ts,tsx}'],
  darkMode: 'media',
  content: [],
  theme: {
    extend: {},
  },
  plugins: [],
};
```

そして`src`配下に`index.css`を作成し以下を記述してください。

```css: src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

警告が出ていますが、VSCodeの拡張機能`Tailwind CSS IntelliSense`をインストールすると警告が抑制できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/f710867e-61bd-be2c-4b99-8c125b4a27c8.png)

### Reactの設定

`tsconfig.js`に`react`を利用する設定を追加します。

```diff_json: tsconfig.js
  {
        "compilerOptions": {
+           "jsx": "react",
            "module": "Node16",
            "target": "ES2022",
            "lib": [
               "ES2022",
+               "DOM"
            ],
            "sourceMap": true,
            "rootDir": "src",
            "strict": true
      }
  }
```

次に`src`配下に`index.tsx`を作成します。これはWebViewのエントリーポイントとしての役割を持ちます。

```tsx: src/index.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";
import React from "react";
import "./index.css"; // tailwindcssの設定がかかれたCSSファイルを読み込み

const root = createRoot(document.getElementById("app")!);
root.render(<App />); // あとでApp.tsxを作成して呼び出します。
```

### webpackの設定

`webpack.config.js`の設定を以下のように変更します。
以下ではReactとTailwindcssを利用する設定を追記しています。


```js: webpack.config.js
//@ts-check

'use strict';

const path = require('path');
const webpack = require('webpack');

//@ts-check
/** @typedef {import('webpack').Configuration} WebpackConfig **/

/** @type WebpackConfig */
const baseConfig = {
  target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
  mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production')
  resolve: {
    // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
    extensions: ['.ts', '.js', '.tsx']
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'ts-loader'
          }
        ]
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
          'postcss-loader'
        ]
      }
    ]
  },
  devtool: 'nosources-source-map',
  infrastructureLogging: {
    level: "log", // enables logging required for problem matchers
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    }),
  ],
};

/** @type WebpackConfig */
const extensionConfig = {
  ...baseConfig,
  entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
  output: {
    // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
    path: path.resolve(__dirname, "dist"),
    filename: "extension.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
    // modules added here also need to be added in the .vscodeignore file
  },
};

/** @type WebpackConfig */
const webviewConfig = {
  ...baseConfig,
  target: ["web", "es2020"],
  entry: "./src/index.tsx",
  experiments: { outputModule: true },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webview.js",
    libraryTarget: "module",
    chunkFormat: "module",
  },
};


module.exports = [extensionConfig, webviewConfig];
```

### WebViewのHTMLに当たるtsxファイルの作成

先ほど、文字列でHTMLを記述していたものを`tsx`形式のファイルとして記述していきます。
今回は`src`配下に`App.tsx`として作成します。

この時、`Tailwindcss`のクラスが利用できるようになっています。

```tsx: src/App.tsx
import React from 'react';

const vscode = (window as any).acquireVsCodeApi(); // vscodeのAPI

export function App() {
    const handleDestroy = () => {
        // ボタンをクリックした際にpostMessageで実際の処理を呼び出します。
        vscode.postMessage({
            command: 'destroy'
        });
    };

    return (
        <div className="p-4">
            <h1 className="text-2xl font-bold mb-4">Destroy</h1>
            <button
                className="bg-red-500 text-white px-4 py-2 rounded"
                onClick={handleDestroy}
            >
                Destroy
            </button>
        </div>
    );
};
```

### 拡張機能のコマンドの修正

最後に`extension.ts`を修正すれば完了です。

参考にさせて頂いた記事の中で`vscode.Uri.joinPath`が使われているところがありますが、これは仕様変更により`path.join`を使うようになっているそうなので注意してください。

```ts: extension.ts

import * as vscode from 'vscode';
import * as childProcess from 'child_process';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {

	// Destroyコマンドを登録
	const disposable = vscode.commands.registerCommand('my-extension.destroy', () => {

		// WebViewを開く
		const panel = vscode.window.createWebviewPanel(
			'destroy',
			'Destroy',
			vscode.ViewColumn.One,
			{
				enableScripts: true
			}
		);

		// WebViewに表示するHTMLを生成
		panel.webview.html = getWebviewContent(panel.webview, context.extensionUri);

		// WebViewからのメッセージを受信
		panel.webview.onDidReceiveMessage(
			message => {
				switch (message.command) {
					case 'destroy':
						// consoleのコマンドを実行する
						const command = "echo 'バルス'";

						// コマンドを実行
						childProcess.exec(command, (error, stdout, stderr) => {
							if (error) {
								console.error(`exec error: ${error}`);
								return;
							}
							if (stdout) {
								console.log(`stdout: ${stdout}`);
								return;
							}
						});
						return;
				}
			},
			undefined,
			context.subscriptions
		);

	});

	context.subscriptions.push(disposable);
}

// Webview側で使用できるようにuriに変換する関数
function getUri(
	webview: vscode.Webview,
	extensionUri: vscode.Uri,
	pathList: string[]
) {
	// vscode.Uri.joinPath は、利用できなくなったため、path.join を使用
	return webview.asWebviewUri(vscode.Uri.file(path.join(extensionUri.fsPath, ...pathList)));
}

// Nonceを生成する関数
function getNonce() {
	let text = "";
	const possible =
		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
	for (let i = 0; i < 32; i++) {
		text += possible.charAt(Math.floor(Math.random() * possible.length));
	}
	return text;
}

// WebViewに表示するHTMLを生成
function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri) {
	const webviewUri = getUri(webview, extensionUri, ["dist", "webview.js"]);
	const nonce = getNonce();

	return `<!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>Cat Coding</title>
                </head>
                <body>
                    <div id="app"></div>
                    <script type="module" nonce="${nonce}" src="${webviewUri}"></script>
                </body>
            </html>`;
}

// This method is called when your extension is deactivated
export function deactivate() { }
```

### コンパイルと実行

上記の作業が全て完了すると、以下のようなディレクトリ構成になります。
実際に開発する場合はディレクトリを分割しておいたほうがキレイだと思います。

```bash
.
├── package.json
├── package-lock.json
├── postcss.config.js
├── src
│   ├── App.tsx
│   ├── extension.ts
│   ├── index.css
│   ├── index.tsx
│   └── test
│       └── extension.test.ts
├── tailwind.config.js
├── tsconfig.json
└── webpack.config.js
```


`npm run compile`を実行してデバッグ実行すると、以下のように先ほどの`App.tsx`で記述したボタンが出てくるのがわかると思います。Tailwindcssのスタイルも適用されていますね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/31f8f9b2-d74b-e109-6218-84b09be477f0.png)

本当は`watch`イベントにて自動でコンパイルされてホットリロードできるはずなのですが、筆者の環境だとうまく動いていなかったみたいなので何か設定が足りないのかもしれません。（Macで試したときはホットリロードきいていた気がします。）
気が向いたら、検証して追記したいと思います。

ちなみに今回のコマンドでは実害の無い破滅の呪文を唱えています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/c54ace2e-eeb5-f9e0-30ce-b681b410770b.png)


## まとめ

今回は`React`を利用した実装でしたが、`Vue`を使った開発なども行えるようなので気が向いたら記事にしようかなーと思います。
自分が慣れているフレームワークを使って開発ができるのは嬉しいですよね。私は文字列でHTMLを書かなくてはならない状態には多分耐えられないので真っ先に探しました。

今回の記事で作成したリポジトリは以下で公開しています。
作成する際は参考にしてください。

https://github.com/nikawamikan/my_extension

次回は本題、とあるツールのGUI版を作って行く予定です。

それでは。