LoginSignup
420
393
お題は不問!Qiita Engineer Festa 2023で記事投稿!

知識0の状態からたった2時間でVSCodeの拡張機能を作った話

Last updated at Posted at 2023-07-10

はじめに

こんにちはkenです。エディターはVS Codeを使ってます。
突然ですがみなさん、普段開発をしていて 「VS Code にこんな拡張機能、あったらいいのにな〜」 と思うことありませんか?
私はあります。しょっちゅうあります。

そこで先日、予定がない休日を利用して拡張機能の開発に挑戦してみることにしました。

最初は拡張機能の開発をどのように進めていけばよいのか全くわからず、そもそも拡張機能でどこまでのことを実現できるのかすらわかりませんでした。ましてや実装についての知識なんて皆無です。

「これは完成まで1日くらい、いや下手すると3日くらいかかるかな」と考えていたのですが、いざやってみるとたった2時間で作れてしまったので今回はその経験についてお話ししたいと思います。
この記事を読んで、 「こんなにお手軽なら自分にも作れそうだ!」 と感じてくれたら幸いです。

本題

作りたかったものと実際作ったもの

私が今回作ろうとした拡張機能の要件は以下です。

  • VS Codeのデバッグのサイドバーのところにテキストエリアを追加する
  • 「実行」ボタンを押すとデバッグセッションが開始され、テキストエリアに入力した内容がターミナルに入力される(標準入力として扱われPythonのコードが実行される。)

★ イメージ図
image.png

なんのためにこんな拡張機能を作るのか(興味のない方は読まなくても大丈夫です)

結論から書くと、私の趣味の競技プログラミングで、書いたコードをデバッグする際に、入力例をいちいちコンテストサイトからコピーしてくるのが面倒に感じられたからです。

競技プログラミングでは書いたコードが思うように動かないとき、以下の手順でデバッグをします。

  1. デバッグセッションを開始し問題とともに用意されている入力例をコンテストサイトからコピーして貼り付ける
  2. ステップ実行でバグの位置を探し、見つけたら該当部分を修正する
  3. 期待している出力が得られない場合、1に戻る

sample.gif

(上の動画で、入力例をコピーするためにエディターとコンテストサイトを行ったり来たりしているのがわかるはず)
うまくいかない場合、このサイクルを10回以上繰り返すこともあります。
その際、1のステップで毎回入力例をターミナルに貼り付けるのは少々煩わしいです。
また、クリップボードの内容が変わらないなら毎回貼り付けをするだけでいいのですが、2のステップでバグ修正をしているときにコードをコピーするようなことがあれば、またコンテストサイトに行って入力例をコピーする必要があります。
こうなると結構面倒です。
別のテキストファイルに入力例を保存しておき、デバッグの際はそのファイルから入力を受け取るようにするといったこともできるのですが、最終的なコード提出時に入力の受け取り方を標準入力に変更するのを忘れるリスクがあります。そのため、できればコード自体を変更せずに、VS Codeの拡張機能を活用してデバッグ環境を整えたいです。そんな思いからこの拡張機能を作ることにしました。

おそらく文章だけ読んでもどんな機能をもった拡張機能なのかわかりづらいと思うので、実際に完成したものをご覧いただきたいと思います。このふわっとした要件からできあがったものがこちらです。
my-extension.gif
テキストエリアに打ち込んだ内容が、ワンクリックでターミナルへ入力されているのがわかるでしょうか。
自分でいうのもなんですが、当初思い描いていたものが作れた気がしています。

作成の流れを知る

ここからは如何にして知識0の状態からこの拡張機能を作ったのかについてお話していきたいと思います。
最初に作ろうと思い立ったとき、まずはなにから始めれば良いのかわからなかったのでとりあえず「VS Code 拡張機能 開発」と検索しました。そしてヒットした次のサイトを読みました。

この記事からは以下のことを学びました。

  • yo codeを実行することでVS Codeの拡張機能の雛形が自動で作成されるということ
  • extension.tsや package.jsonを編集することで拡張機能の中身が作れるということ
  • F5を押すと別ウィンドウでVS Codeが開き、作成中の拡張機能の挙動をテストできるということ

ありがたいことにたいへん詳しく説明されていたので、迷うこと無く私も雛形を作成し拡張機能の開発を始めることができました。
しかし勢いづいたのもつかの間、私の頭に思い浮かんだのは…

「ここからどうすればええんや…」

ということでした。

雛形を作れたのはいいですが、ここから果たしてどんなコードを書けば自分の思い描いている拡張機能が作れるのかがわからず、一気に手が止まってしまいました。

「なにかサンプルみたいなものがあれば雰囲気がわかって開発しやすくなるかも…??」
そんな思いで、参考になりそうな拡張機能の実装を探しに行くことにしました。

公式がサンプルを用意してくれていた

ちょっと調べてみるとなんとVS Codeの公式が拡張機能のサンプルを用意してくれているではありませんか!!しかもたくさん!!

様々あるサンプルをひとつずつ確認していくと、そのなかのwebview-view-sampleが自分の実現したいものに近そうだということを発見しました。
というのもこのサンプルは「Calico Colors」(日本語で三毛猫色)という名前の拡張機能で、次のような機能を実装したものでした。

  • エクスプローラーのサイドバーに「Add Color」というボタンを追加
  • 「Add Color」をおすとサイドバーに三毛猫にちなんだ色がランダムに追加されていく
  • コマンドパレットからでもサイドバーに色を追加したり、削除したりすることができる
  • 色プレビューをクリックすると、その色のカラーコードがアクテイブなテキストエディターに挿入される

★ 実際にサンプルを動かしている様子
Calico.gif

このサンプルは、少し改造するだけで自分の期待するものになりそうです。
というのは、先ほど挙げた3つの機能を次のように改造すればよいからです。

  • エクスプローラーのサイドバーに「Add Color」というボタンを追加
    → 「Add Color」のかわりにテキストエリアと「実行」のボタンを追加
  • 「Add Color」をおすとサイドバーに三毛猫にちなんだ色がランダムに追加されていく
    「実行」を押すとターミナルにテキストエリアの内容が入力されるように
  • コマンドパレットからでもサイドバーに色を追加したり、削除したりすることができる
    今回はコマンドパレットからの操作は必要ない

やることは今動いているサンプルを少しずつ改造していくだけです。そう考えるとなんだか一気にいけそうな感じがでてきました…!!

改造開始!

さて、ではひとつずつサンプルをいじって機能を自分が作りたいものに近づけていきます。

テキストエリアを追加する

まずはextension.tsのソースコードを読んでみます。

するといかにもサイドバーの体裁を整えているような箇所を発見できます。

extension.ts
private _getHtmlForWebview(webview: vscode.Webview) {
		// Get the local path to main script run in the webview, then convert it to a uri we can use in the webview.
		const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'main.js'));

		// Do the same for the stylesheet.
		const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'reset.css'));
		const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'vscode.css'));
		const styleMainUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'main.css'));

		// Use a nonce to only allow a specific script to be run.
		const nonce = getNonce();

		return `<!DOCTYPE html>
			<html lang="en">
			<head>
            // 中略
			</head>
			<body>
				<ul class="color-list">
				</ul>

				<button class="add-color-button">Add Color</button>

				<script nonce="${nonce}" src="${scriptUri}"></script>
			</body>
			</html>`;
	}

あのボタンなどのUIはHTMLとJavaScriptで作られていたんですね!!そうとわかれば一気に馴染み深い感じがしてきました、早速改造してみましょう!

やることはテキストエリアを追加してボタンの文言を変えるだけです。ついでにもともと三毛猫色が追加されていくはずだったulタグを消しておきます。すると先ほどのbodyタグは次のようになります。

<body>
  <textarea id="command-input" cols="5" rows="5"></textarea>
  <button class="execute-python-code">実行</button>

  <script nonce="${nonce}" src="${scriptUri}"></script>
</body>

このたった数行の修正で見た目は次のように変わります…!!私はここでちょっと感動しました(;;)
image.png
なお、修正したあとF5で挙動の確認をする前にはnpm run compileを実行することを忘れないようにしてください。(でないと修正内容が反映されないので)

「実行」を押すとテキストエリアの内容を読み取り VS Code の API にわたす

テキストエリアをサイドバーに追加することはできたので、次はその内容を読み取ってVS CodeのAPIに渡すまでの処理を作っていきましょう。

ここでもサンプルの実装を真似して作っていきたいです。
もともとのサンプルでは「Add Color」ボタンを押すとランダムに三毛猫色が追加されていってましたが、あの処理はどこに書かれていたのでしょうか?
その答えは先ほどのHTMLで読み込まれていた main.js です。

一部抜粋したものを下に並べます。

sampleのmain.js ①
    document.querySelector('.add-color-button').addEventListener('click', () => {
        addColor();
    });
sampleのmain.js ②
    function addColor() {
        colors.push({ value: getNewCalicoColor() });
        updateColorList(colors);
    }
sampleのmain.js ③
    function updateColorList(colors) {
        const ul = document.querySelector('.color-list');
        ul.textContent = '';
        for (const color of colors) {
            // 筆者注:HTML要素を動的に生成
            const li = document.createElement('li');
            li.className = 'color-entry';

            const colorPreview = document.createElement('div');
            colorPreview.className = 'color-preview';
            colorPreview.style.backgroundColor = `#${color.value}`;
            colorPreview.addEventListener('click', () => {
                // 筆者注:ここで色プレビューに対してイベントリスナーを設定
                onColorClicked(color.value);
            });
            li.appendChild(colorPreview);

            const input = document.createElement('input');
            input.className = 'color-input';
            input.type = 'text';
            input.value = color.value;
            input.addEventListener('change', (e) => {
                const value = e.target.value;
                if (!value) {
                    // Treat empty value as delete
                    colors.splice(colors.indexOf(color), 1);
                } else {
                    color.value = value;
                }
                updateColorList(colors);
            });
            li.appendChild(input);

            ul.appendChild(li);
        }

        // Update the saved state
        vscode.setState({ colors: colors });
    }
sampleのmain.js ④
    function onColorClicked(color) {
        vscode.postMessage({ type: 'colorSelected', value: color });
    }

ふむふむ。なんとなくわかってきました。つまりこのmain.jsでは次のようなことを行っているみたいです。

  1. ① でadd-color-buttonクラスを持つ要素がクリックされたとき、addColor()関数を実行するというように設定します。
  2. ② のaddColor()関数ではupdateColorList関数を呼び出す。
  3. ③ のupdateColorList関数ではHTMLドキュメントからcolor-listというクラス名を持つ最初の要素を選択し、そのテキスト内容を空にリセットする。そして渡されたcolors配列の各要素に対してHTML要素を動的に生成し、それぞれの色プレビューには、クリックイベントリスナーを設定する。最後に、現在のcolors配列の状態をVS CodeのWebview APIのsetStateメソッドを用いて保存する。(これにより、次回Webviewが再読み込みされた時でも、最後にユーザーが設定した色リストの状態を復元できるようにしている。)
  4. ④ で色プレビューがクリックされたときに叩かれるonColorClicked関数を定義する。ここではどの色がクリックされたのかをVS CodeのWebview APIを通じてメッセージを送信している。

これを自分用に書き換えてみます。やることは次の2 つです。

  1. まず先ほど私が加えた「実行」ボタンはクラスとしてexecute-python-codeを設定していたので、クラス名をそれに書き換える。つまり.add-color-button.execute-python-codeへと書き換える。
  2. 続いて、「実行」ボタンをクリックしたときに次の処理が走るようにする。
    • (command-inputというidが割り当てられている)テキストエリアに入力された内容を取得する
    • VS CodeのWebview APIを介してメッセージを送信する

1つめの書き換えは ① のクラス名を変更するだけで、2つめの書き換えは ④ を少し真似するだけです。
また、その他にmain.jsに書かれたコードはすべて必要ないものなので全部消してしまいます。そうすると残ったmain.jsのコードはたった 9 行 です

main.js
(function () {
    const vscode = acquireVsCodeApi();

    document.querySelector('.execute-python-code').addEventListener('click', () => {
        const commandInput = document.querySelector('#command-input').value;
        vscode.postMessage({ type: 'executeCommand', value: commandInput });

    });
}());

なおacquireVsCodeApiはVS CodeのWebview APIを取得するもので、このAPIを通じてWebviewとVS Codeの間でデータを送受信することができるようになります。

API で送られてきたテキストエリアの内容をターミナルに入力する

再度extension.tsの内容を見てみます。
するとどうやらさっきvscode.postMessageでAPIに渡した内容を受け取って行う処理を定義できるみたいです。
サンプルでは色プレビューをクリックするとカラーコードのスニペットが入力される仕様になっていました。実際にコードを見ていると確かに、「アクティブになっているテキストエディターを選択してスニペットを挿入する」というコードになってますね。

sampleのextension.ts(抜粋)
		webviewView.webview.onDidReceiveMessage(data => {
			switch (data.type) {
				case 'colorSelected':
					{
						vscode.window.activeTextEditor?.insertSnippet(new vscode.SnippetString(`#${data.value}`));
						break;
					}
			}
		});

よってこの部分を自分用に、つまりターミナルにテキストエリアの内容が入力されるように改造してみます。

extension.ts

const PYTHON_DEBUG_CONSOLE = 'Python Debug Console';
const findPythonDebugConsole = () => vscode.window.terminals.find(t => t.name === PYTHON_DEBUG_CONSOLE);

webviewView.webview.onDidReceiveMessage(async data => {
    switch (data.type) {
        case 'executeCommand': {
            let terminal = findPythonDebugConsole();
            if (!terminal) {
                const debugConfig = {
                    type: "python",
                    request: "launch",
                    name: "Launch Python File",
                    program: "${file}",
                    console: "integratedTerminal",
                };

                await vscode.debug.startDebugging(undefined, debugConfig);
                terminal = findPythonDebugConsole();
            }

            if (terminal) {
                terminal.show();
                terminal.sendText(data.value);
            }
        }
    }
});

少し長いですが、やっていることは単純です。executeCommandというtypeのメッセージを受信した際に次の処理を行うようにしています。

  1. Pythonのデバッグコンソールが開かれているかを確認する。
  2. 開かれていなければ新たにターミナルを作成し、Pythonファイルを実行する設定 (debugConfig) を用いてデバッグを開始する。デバッグが開始したら、再度PYTHON_DEBUG_CONSOLE(= "Python Debug Console")という名前のターミナルを探す。
  3. ターミナルが見つかった場合、そのターミナルを表示しメッセージとして送られてきた値をそのターミナルに送信する。

ここまでの修正を加えると機能としてはほとんどできあがったものになります。
terminal.gif

残されたやるべきことは次の2つです。

  • 今は拡張機能がエクスプローラーのところに表示されているので、それをデバッグのところに移す
  • サイドバーに表示される拡張機能がCALICO COLORSという名前になっているので、それを別の名前に変更する

これらはpackage.jsonをいじることで解決できます。

最後の仕上げに package.json を修正する

package.jsonの現在の状態を一部抜粋したものが以下になります。

package.json
	"contributes": {
		"views": {
			"explorer": [
				{
					"type": "webview",
					"id": "calicoColors.colorsView",
					"name": "Calico Colors"
				}
			]
		},
		"commands": [
			{
				"command": "calicoColors.addColor",
				"category": "Calico Colors",
				"title": "Add Color"
			},
			{
				"command": "calicoColors.clearColors",
				"category": "Calico Colors",
				"title": "Clear Colors",
				"icon": "$(clear-all)"
			}
		],
		"menus": {
			"view/title": [
				{
					"command": "calicoColors.clearColors",
					"group": "navigation",
					"when": "view == calicoColors.colorsView"
				}
			]
		}
    }

これを見てみると、viewsという項目が拡張機能をサイドバーのエクスプローラーのところに表示させるように設定しているようです。
また、commandsというのは色の追加や削除のといったコマンドを設定しており、menusは拡張機能の上部にあった色を削除するボタンについての記述を行っているみたいです。

今回はcommandsmenusに関しては必要がないので消してしまって、viewsのところを次のように変更します。

package.json
		"views": {
			"debug": [
				{
					"type": "webview",
					"id": "PythonInput.InputView",
					"name": "Python Input"
				}
			]
        }

また、ここで入力したidというのはextension.tsに定義しているProviderのviewTypeに対応しているので、そこも一緒に書き換えます。

diff_extension.ts
- class ColorsViewProvider implements vscode.WebviewViewProvider {
+ class PythonInputViewProvider implements vscode.WebviewViewProvider {
- 	public static readonly viewType = 'calicoColors.colorsView';
+ 	public static readonly viewType = 'PythonInput.InputView';
    // (以下略)

これでサイドバーのデバッグのところに「PYTHON INPUT」という名前で拡張機能が表示されるようになりました。
ついにこれで実装したかった機能すべてが実現できました…!!!!
final.gif

おわりに

いかがでしたでしょうか。
拡張機能の開発経験が全くない私でも、サンプルをもとに実装を進めることでスムーズに実装が進められたことがおわかりいただけたかと思います。豊富なサンプルを用意してくれているMicrosoftさんに感謝です…!!
今回作成した拡張機能のソースコードはGitHubで公開しています。

またリリースもしているので興味のある方はぜひ手元で動かしてみてください。

間違いなどありましたらコメントにてご指摘ください。ここまで読んでいただきありがとうございました。

420
393
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
420
393