🤖

Webview APIを使ったVSCode拡張機能の開発方法をハンズオンっぽく解説

2024/08/22に公開

はじめに

先日初めてVSCodeの拡張機能を作って公開しました。

https://marketplace.visualstudio.com/items?itemName=ikoamu.side-clipboard

VSCodeの拡張機能の開発した際に得た知見を記事にしようと思っていたのですが、作ったものがよくある「ハンズオンで作るTodoアプリ」っぽいものになったので、雛形の作成からの実装過程をハンズオンっぽく書いていみました。

拡張機能を作ってみた経緯

日々、プロジェクトで色々なコマンドを叩いているのですが使うコマンドが多すぎて覚えられなくなってきました。
その都度READMEを確認してコマンドをコピペして実行していたのですが、コマンドを登録しておいてVSCodeのサイドバーから実行できたら便利なのでは?と思い自分用に作りました。

機能

以下のような機能を実装しました。

  • Activitybar(VSCodeの左端のExploreや拡張機能のアイコンが並んでいるところ)に自分の作成した拡張機能のアイコンが表示される
  • そのアイコンをクリックすると、サイドバーに登録済みのテキスト一覧と新規テキスト追加のInputが表示される
  • サイドバーから登録済みテキストをクリップボードにコピー、またはターミナルに貼り付けできる
  • 登録したテキストを削除できる
  • テキスト登録コマンドを作成し、サイドバーを開かずに登録することができる

本記事ではこれらの機能をどう実装したかすべて解説していきます。

また、表示するWebviewを VSCodeの拡張機能っぽい 見た目にする方法も解説しています。

実装

雛形作成

https://code.visualstudio.com/api/get-started/your-first-extension に従って yeoman-generator で雛形を作成します。

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


     _-----_     ╭──────────────────────────╮
    |       |    │   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? unbundled
? Which package manager to use? npm
? Do you want to open the new folder with Visual Studio Code? Open with `code`

雛形が作成されたらVSCodeを開き、F5でとりあえず実行してみます。
すると、別ウィンドウで新しいVSCodeが開かれます。

新しく開かれたVSCodeのコマンドパレットからHello World コマンドが実行できれば雛形の作成完了です。
(コマンドを実行するとウィンドウの右下にメッセージが表示されます)

サイドメニューにWebviewを表示する

次に、サイドメニューの画面を作っていきます。今回は入力フォームや登録済みのテキストなどをいい感じに表示させたいため、Tree View APIではなく、Webview APIを使ってHTML、JS、CSSをゴリゴリに書いていきます。

公式のサンプルがあるので、そちらを参考に実装していきます。

https://github.com/microsoft/vscode-extension-samples/tree/main/webview-view-sample

package.jsonにviewsとviewsContainersを設定する

サイドメニューにWebviewを表示させるためには、まずpackage.jsonのcontributesviewsviewsContainers に諸々の設定を記述する必要があります。
また、activitybarに表示するアイコンも必要になります。

package.json
{
  ...
  "contributes": {
    "commands": [
      {
        "command": "my-extension.helloWorld",
        "title": "Hello World"
      }
    ],
+    "views": {
+      "my-extension-view": [
+        {
+          "type": "webview",
+          "id": "myExtension.view",
+          "name": "My Extension Name"
+        }
+      ]
+    },
+    "viewsContainers": {
+      "activitybar": [
+        {
+          "id": "my-extension-view",
+          "title": "My Extension TITLE",
+          "icon": "resources/activitybarIcon.svg"
+        }
+      ]
+    }
+  },
  ...
}
  • activitybarのidは必ずviewsに追加したもののkey(ここではmy-extension-view)にする必要があります。
  • activitybarのtitleはアクティブバーのアイコンをホバーした際に表示されます。
  • サイドバー上部には<activitybar.title>:<views.name>の文字列が表示されます。
    • titleとnameが同じ場合は以下のように表示されます。
  • 今回activitybarに表示させるアイコンは、VSCodeで利用されているcodiconを使いました。
    以下のサイトからダウンロードし、リポジトリ直下にresourcesディレクトリを作成し、activitybarIcon.svgとして保存します。

https://icon-sets.iconify.design/codicon/

WebviewViewProviderを作成し、HTMLをサイドバーに表示させる

package.jsonに設定を記述したら実際にサイドバーに任意のhtmlを表示する実装をしていきます。
とりあえず適当な見出しを表示させてみます。

src/extension.ts
import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  // 下で実装したWebviewViewProviderクラスのインスタンスをnewする
  const provider = new MyExtensionViewProvider(context.extensionUri);
  // providerをregisterWebviewViewProvider()で登録する
  context.subscriptions.push(
    vscode.window.registerWebviewViewProvider(
      // 👇 package.jsonに記述したviewsのidを設定する 👇
      "myExtension.view",
      provider,
    ),
  );
}

class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;
  
  constructor(private readonly extensionUri: vscode.Uri) {}
 
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;

    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
      </body>
      </html>`;
  }
}

これでHTMLが表示できました🎉

JSファイルを読み込む

次にJSファイルを読み込めるようにしてテキストの登録と登録したテキスト一覧を表示できるようにします。
まず、JSファイルを作成します。(今回は公式のサンプルに倣ってmedia/main.jsとします。)

試しにJSファイルが読み込まれたら<ul>タグ内にテキストが挿入されるようにしてみます。

media/main.js
const ul = document.getElementById("list");
ul.innerText = "command list";

次に先ほど作成したMyExtensionViewProviderでスクリプトを読み込むようにします。

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;
  
  constructor(private readonly extensionUri: vscode.Uri) {}
 
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
+   // scriptを実行するためには以下の設定が必要
+   webviewView.webview.options = {
+     enableScripts: true,
+   };
+   // 作成したJSファイルのURIを取得する
+   const scriptUri = webviewView.webview.asWebviewUri(
+     vscode.Uri.joinPath(this.extensionUri, "media", "main.js")
+   );

    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
+       <!-- スクリプトでテキストを挿入するulタグを追加 -->
+       <ul id="list" />
+       <!-- JSファイルを読み込む -->
+       <script src="${scriptUri}"></script>
      </body>
      </html>`;
  }
}

テキストが挿入されました🎉

テキスト登録フォームと登録したテキスト一覧を表示する

次にInputをサイドバー上に表示し、そこから登録した値を一覧で表示できるようにしていきます。
まずはInputと登録ボタンを作ります。

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    ...
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
+       <input type="text" id="register-input" />
+       <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" />
       	<!-- JSファイルを読み込む -->
       	<script src="${scriptUri}"></script>
      </body>
      </html>`;
  }
}

テキスト登録処理

jsファイルを以下のように書き換えます。

media/main.js
let items = [];
const registerInput = document.getElementById("register-input");
const registerButton = document.getElementById("register-button");
registerButton.addEventListener("click", () => {
  const value = registerInput.value;
  if (value) {
    items.push(value);
    registerInput.value = ""; // 登録後、Inputの値をリセットする
    console.log("register", items);
  }
});

「登録」ボタンを押下したらInputに入力された値をitemsに追加していきます。
登録したテキストを一覧で表示はこの後行うので、一旦登録後のitemsの状態をコンソール上にログ出力します。

テキスト一覧を表示する

次にitemsからリストを作成する関数updateItems()を作成します。
そして、初期表示時とリストを追加した直後に実行されるようにします。

media/main.js
let items = [];
...
registerButton.addEventListener("click", () => {
  const value = registerInput.value;
  if (value) {
    items.push(value);
    registerInput.value = "";
-   console.log("register", items);
+   // リストを更新する
+   updateItems();
  }
});
+ 
+ function updateItems() {
+   const list = document.getElementById("list");
+   list.innerHTML = "";
+   items.forEach((item) => {
+     const li = document.createElement("li");
+     li.textContent = item;
+     list.appendChild(li);
+   });
+ }
+ // リストの初期化
+ updateItems();

これで「登録」ボタンを押下すると登録したテキストの一覧が表示されるようになりました

登録したテキストを削除できるようにする

テキストの登録・一覧表示ができたので次にテキストを削除処理を実装します。

削除ボタンを表示する

まず、<li> タグ内にテキストと削除ボタンを表示するようにします。

media/main.js
function updateItems() {
  const list = document.getElementById("list");
  list.innerHTML = "";
  items.forEach((item) => {
    const li = document.createElement("li");
+   const deleteButton = document.createElement("button");
+   deleteButton.textContent = "削除";
+   li.appendChild(document.createTextNode(item));
+   li.appendChild(deleteButton);
-   li.textContent = item;
    list.appendChild(li);
  });
}
...

(見栄えが終わっていますが一旦このまま進めていきます。)

削除ボタン押下でテキストが削除されるようにする

削除ボタンを押下した時のイベントをイベントリスナーに追加します。
今回はクリックしたテキストと一致する要素をitemsから除外するようにしています。
(そのため、同じテキストが複数ある場合はそれら全部が消えてしまうようになっています)

media/main.js
function updateItems() {
  const list = document.getElementById("list");
  list.innerHTML = "";
  items.forEach((item) => {
    const li = document.createElement("li");
    const deleteButton = document.createElement("button");
+   // 削除ボタンがクリックされたときの処理
+   deleteButton.addEventListener("click", () => {
+     items = items.filter((i) => i !== item);
+     updateItems(); // リストを更新する
+   });
    deleteButton.textContent = "削除";
    li.appendChild(document.createTextNode(item));
    li.appendChild(deleteButton);
    li.textContent = item;
    list.appendChild(li);
  });
}
...

登録済みテキストをクリップボードにコピー・ターミナルにペーストできるようにする

削除ボタンと同様に<li>タグ内に「クリップボードにコピー」ボタン・「ターミナルにペースト」ボタンを追加し、2つのボタンのクリックイベントをリスナーに追加します。

media/main.js
+ const vscode = acquireVsCodeApi();
let items = [];
...
function updateItems() {
  const list = document.getElementById("list");
  list.innerHTML = "";
  items.forEach((item) => {
    const li = document.createElement("li");
    const deleteButton = document.createElement("button");
    // 削除ボタンがクリックされたときの処理
    deleteButton.addEventListener("click", () => {
      items = items.filter((i) => i !== item);
      updateItems(); // リストを更新する
    });
    deleteButton.textContent = "削除";
+   // クリップボードにコピーボタン
+   const copyToClipboardButton = document.createElement("button");
+   copyToClipboardButton.textContent = "コピー";
+   copyToClipboardButton.addEventListener("click", () => {
+     // 💡拡張機能側にメッセージを送信
+      vscode.postMessage({
+        type: "copyToClipboard",
+        text: item,
+     });
+   });
+   // ターミナルにペーストボタン
+   const pasteToTerminalButton = document.createElement("button");
+   pasteToTerminalButton.textContent = "ペースト";
+   pasteToTerminalButton.addEventListener("click", () => {
+     // 💡拡張機能側にメッセージを送信
+     vscode.postMessage({
+       type: "pasteToTerminal",
+       text: item,
+      });
+   });
    li.appendChild(document.createTextNode(item));
    li.appendChild(deleteButton);
+   li.appendChild(copyToClipboardButton);
+   li.appendChild(pasteToTerminalButton);
    list.appendChild(li);
  });
}

この2つのボタンをクリックした時の処理はWebview側(media/main.js)ではなく、 VSCode拡張側(src/extension.ts)で行う必要があります。
そのため、ボタンをクリックした際にvscode.postMessage()でWebviewから拡張機能へメッセージを受け渡し、MyExtensionViewProviderでコピー処理やターミナルへの貼り付け処理を実施します。

https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-a-webview-to-an-extension

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    ...
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `...`;

+   // Webviewからメッセージを受け取った時の処理
+   webviewView.webview.onDidReceiveMessage((data) => {
+     switch (data.type) {
+       case "copyToClipboard":
+         // クリップボードにコピーする処理
+         break;
+       case "pasteToTerminal":
+         // テキストをターミナルに貼り付ける処理
+         break;
+     }
+   });
+ }
}

クリップボードにコピー

MyExtensionViewProvider にテキストをクリップボードにコピーするメソッドを追加し、onDidReceiveMessagedata.typecopyToClipboard の場合にそのメソッドを呼び出すようにします。
クリップボードへのコピーはvscode.env.clipboard.writeText()を使います。

https://code.visualstudio.com/api/references/vscode-api#env

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    ...
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `...`;

    // Webviewからメッセージを受け取った時の処理
    webviewView.webview.onDidReceiveMessage((data) => {
      switch (data.type) {
        case "copyToClipboard":
-         // クリップボードにコピーする処理
+         this._copyToClipboard(data.text);
          break;
        case "pasteToTerminal":
          // テキストをターミナルに貼り付ける処理
          break;
      }
    });
  }

+  private _copyToClipboard(text: string) {
+    // クリップボードにコピー
+    vscode.env.clipboard.writeText(text);
+    // コピーしたことをメッセージで表示する
+    vscode.window.showInformationMessage(`📋Copied to clipboard \"${text}\"`);
+  }
}

ターミナルにペースト

クリップボードにコピーと同様にメソッドを作成し、onDidReceiveMessageに記載します。
ターミナルにテキストを入力するには terminal.sendText()を使います。

https://code.visualstudio.com/api/references/vscode-api#Terminal

テキストを入力する対象のターミナルは以下のように決定するようにします。

  • vscode.window.activeTerminal() でアクティブなターミナルが取得できる場合
    • そのターミナルにテキストを入力する
  • vscode.window.activeTerminal() でアクティブなターミナルが取得できなかった場合
    • vscode.window.terminals でターミナルが取得できた場合はvscode.window.terminals[0] にテキストを入力する
    • vscode.window.terminals でターミナルが取得できなかった場合は vscode.window.createTerminal() で新規のターミナルを開き、そこにテキストを入力する
src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    ...
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `...`;

    // Webviewからメッセージを受け取った時の処理
    webviewView.webview.onDidReceiveMessage((data) => {
      switch (data.type) {
        case "copyToClipboard":
          this._copyToClipboard(data.text);
          break;
        case "pasteToTerminal":
-         // テキストをターミナルに貼り付ける処理
+         this._pasteToTerminal(data.text);
          break;
      }
    });
  }

...

+  private _pasteToTerminal(text: string) {
+    const paste = (terminal: vscode.Terminal) => {
+      /// 入力したテキストを即時に実行しないために、shouldExecuteはfalseにしている
+      terminal.sendText(text, false);
+      // terminalにフォーカスする
+      terminal.show();
+    };
+
+    // 現在アクティブなターミナルを取得する
+    const activeTerminal = vscode.window.activeTerminal;
+    if (activeTerminal) {
+      paste(activeTerminal);
+      return;
+    }
+
+    // VSCodeで開いているターミナル一覧を取得する
+    const terminals = vscode.window.terminals;
+    if (terminals.length) {
+      paste(terminals[0]);
+    } else {
+      // 新規のターミナルを作成する
+      const newTerminal = vscode.window.createTerminal();
+      paste(newTerminal);
+    }
+  }
}

登録コマンドを作成する

サイドバーのInputからだけではなく、VSCodeのInputBoxを使ってクリップボードに保存されている値を瞬時に登録できるコマンドを作成します。

package.jsonにcommandsを設定する

package.jsonにviewsviewsContainersを設定するのようにpackage.jsonに登録コマンドを設定します。

package.json
{
  ...
  "contributes": {
    "commands": [
+     {
+       "command": "my-extension.addItemFromInputBox",
+       "title": "Add Item from InputBox"
+     }
-     {
-       "command": "my-extension.helloWorld",
-       "title": "Hello World"
-     }
    ],

(ついでに雛形で作成されたHello Worldコマンドを削除しています。)

コマンド実行時の処理を拡張機能に実装する

src/extension.ts
export function activate(context: vscode.ExtensionContext) {
  const provider = new ItemsViewProvider(context.extensionUri);
  context.subscriptions.push(
    vscode.window.registerWebviewViewProvider(
      ItemsViewProvider.viewType,
      provider,
    ),
  );
+ // コマンドを登録する
+ context.subscriptions.push(
+   vscode.commands.registerCommand(
+     // package.jsonに設定した`commands.command`を設定する
+     "my-extension.addItemFromInputBox",
+     () => {
+       provider.addItemFromInputBox();
+     }
+   ),
+ );
}

export class ItemsViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;

  public static readonly viewType = "sideClipboard.itemsView";

  constructor(private readonly extensionUri: vscode.Uri) {}

+  public addItemFromInputBox() {
+   // 現在クリップボードに保存されている値を取得し、InputBoxの初期値とする
+   vscode.env.clipboard.readText().then((text) => {
+     if (!this._view) {
+       return;
+     }
+     const input = vscode.window.createInputBox();
+     input.placeholder = "New Item";
+     input.value = text;
+     // InputBoxでEnterが押された時のイベント登録
+     input.onDidAccept((_) => {
+       if (!input.value) {
+         return;
+       }
+       // Webview側にメッセージを渡す
+       this._view?.webview.postMessage({
+         type: "addItem",
+         text: input.value,
+       });
+       // InputBoxを閉じる
+       input.hide();
+     });
+
+     // InputBoxを表示する
+     input.show();
+   });
+ }

  public resolveWebviewView(webviewView: vscode.WebviewView) {
    ...
  }
}

コピー・ターミナルへペースト機能とは逆に、拡張側からWebviewにメッセージを渡します。

https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-an-extension-to-a-webview

media/main.js
...

function updateItems() { ... }

+ window.addEventListener("message", (event) => {
+   const message = event.data;
+   switch (message.type) {
+     case "addItem": {
+       items.push(message.text);
+       updateItems();
+       break;
+     }
+   }
+ });

// リストの初期化
updateItems();

登録したテキストを永続化する

VSCodeが閉じられても登録したテキストがなくならないように items の値を永続化させます。
main.js読み込み時にvscode.getState()で永続化した値を取得し、itemsを変更するたびにvscode.setState() で永続化している値を更新します。

https://code.visualstudio.com/api/extension-guides/webview#getstate-and-setstate

media/main.js
const vscode = acquireVsCodeApi();
+ // 永続化されたstateを取得
+ const previousState = vscode.getState();
+ let items = previousState?.items ?? [];
- let items = [];

...

function updateItems() {
+  // itemsが更新されたタイミングで値を保存する
+  vscode.setState({ items });
  const list = document.getElementById("list");
  list.innerHTML = "";
  items.forEach((item) => {
    ...
  });
}

レイアウトを整える

値の永続化までができたので、次にCSSで見た目を整えていきます。

CSSファイルを読み込む

JSファイルを読み込む同様に、media/main.cssを作成し、resolveWebviewView 内のHTMLでcssファイルを読み込むようにします。
試しにheaderの文字色を赤にしてみます。

media/main.css
h1 {
  color: red;
}
src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;

  constructor(private readonly extensionUri: vscode.Uri) {}
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    // scriptを実行するためには以下の設定が必要
    webviewView.webview.options = {
      enableScripts: true,
    };
    // 作成したJSファイルのURIを取得する
    const scriptUri = webviewView.webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "media", "main.js")
    );
+   // 作成したCSSファイルのURIを取得する
+   const styleUri = webviewView.webview.asWebviewUri(
+     vscode.Uri.joinPath(this.extensionUri, "media", "main.css")
+   );
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+     	<!-- CSSファイルを読み込む -->
+       <link rel="stylesheet" href="${styleUri}">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
          <input type="text" id="register-input" />
          <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" />
       	<!-- JSファイルを読み込む -->
       	<script src="${scriptUri}"></script>
      </body>
      </html>`;
  ...
}

VSCodeのテーマカラーを利用する

WebviewはCSSカスタムプロパティを使ってVSCodeのテーマカラーを参照することができます。
これにより、ユーザーがどんなThemeを使っていたとしても、自然なデザインになってくれます。

https://code.visualstudio.com/api/extension-guides/webview#theming-webview-content

ウェブビューはCSS変数を使ってVS Codeのテーマカラーにアクセスすることもできます。 これらの変数名は先頭にvscodeを付け、.-に置き換えます。 例えばeditor.foregroundvar(--vscode-editor-foreground)となります。

使用可能なテーマ変数については、テーマカラーリファレンスを参照してください。 変数のインテリセンス候補を提供する拡張機能が利用できます。

ここの色は何を使っているんだろう?という時は開発者ツールから変数名を確認しながら実装しました。

media/main.css
.register-form-container {
  display: flex;
}

.register-input {
  border: 1px solid var(--vscode-input-border);
  border-radius: 2px;
  background-color: var(--vscode-editor-background);
  color: var(--vscode-editor-foreground);
  width: 100%;
}

.register-button {
  background-color: var(--vscode-button-background);
  color: var(--vscode-button-foreground);
  border: 1px solid var(--vscode-button-border);
  border-radius: 2px;
  margin-left: 10px;
  padding: 4px 10px;
  width: 100px;
}
src/media.css
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    ...
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <!-- CSSファイルを読み込む -->
      <link rel="stylesheet" href="${styleUri}">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
+       <div class="register-form-container">
+         <input type="text" id="register-input" class="register-input" />
+         <button id="register-button" class="register-button">登録</button>
+       </div>
-       <input type="text" id="register-input" />
-       <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" class="registered-list" />
       	<!-- JSファイルを読み込む -->
       	<script src="${scriptUri}"></script>
      </body>
      </html>`;
    ...
  }
  ...
}

codiconを使う

次に、Webview上にVSCode上で使われているcodiconを表示します。
今回は例として登録ボタンの「登録」というテキストの部分をcodiconのadd(+)アイコンにしてみます。

まず@vscode/codiconsをnpm等でインストールします。

$ npm i @vscode/codicons

次に、()[]と同様にインストールしたcodiconのcssをWebviewのHTMLに読み込みます。

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;

  constructor(private readonly extensionUri: vscode.Uri) {}
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    // scriptを実行するためには以下の設定が必要
    webviewView.webview.options = {
      enableScripts: true,
    };
    // 作成したJSファイルのURIを取得する
    const scriptUri = webviewView.webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "media", "main.js")
    );
    // 作成したCSSファイルのURIを取得する
    const styleUri = webviewView.webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "media", "main.css")
    );
+   // インストールした`@vscode/codicons`のCSSファイルのURIを取得する
+   const codiconsUri = webviewView.webview.asWebviewUri(
+     vscode.Uri.joinPath(
+       this.extensionUri,
+       "node_modules",
+       "@vscode/codicons",
+       "dist",
+       "codicon.css",
+     ),
+   );
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
     	<!-- CSSファイルを読み込む -->
        <link rel="stylesheet" href="${styleUri}">
+    	<!-- codiconのCSSファイルを読み込む -->
+       <link rel="stylesheet" href="${codiconsUri}">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
          <input type="text" id="register-input" />
          <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" />
       	<!-- JSファイルを読み込む -->
       	<script src="${scriptUri}"></script>
      </body>
      </html>`;
  ...
}

アイコンは<i>タグのclassにcodiconcodicon-XXX(XXX部分はアイコン名)を設定すると表示されます。

src/extension.ts
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
     	<!-- CSSファイルを読み込む -->
        <link rel="stylesheet" href="${styleUri}">
     	<!-- codiconのCSSファイルを読み込む -->
        <link rel="stylesheet" href="${codiconsUri}">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
          <input type="text" id="register-input" />
+         <button id="register-button" class="register-button">
+           <span style="vertical-align:middle">登録</span>
+           <!-- codiconのaddアイコンを表示 -->
+           <i style="vertical-align:middle" class="codicon codicon-add"></i>
+         </button>
-         <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" />
       	<!-- JSファイルを読み込む -->
       	<script src="${scriptUri}"></script>
      </body>
      </html>`;

これでレイアウトの実装は完了です🎉

リソースのアクセスを制御する

機能・レイアウトの実装が完了したので、最後にリソースのアクセスを制御します。

localResourceRootsでローカルリソースへのアクセスを制御する

webviewView.webview.optionslocalResourceRoots を設定することで、webviewがロードするリソースを制限することができます。

https://code.visualstudio.com/api/extension-guides/webview#controlling-access-to-local-resources

今回の例では以下2つのパスを指定します。

  • 自前で実装したjsファイル(main.js)とcssファイル(main.css)が配置されているmediaディレクトリ
  • npm等でnode_modulesにインストールされた@vscode/codicons/distディレクトリ
src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;

  constructor(private readonly extensionUri: vscode.Uri) {}
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    this._view = webviewView;
    webviewView.webview.options = {
      enableScripts: true,
+     localResourceRoots: [
+       vscode.Uri.joinPath(this.extensionUri, "media"),
+       vscode.Uri.joinPath(
+         this.extensionUri,
+         "node_modules",
+         "@vscode/codicons",
+         "dist",
+       ),
+     ],
    };
    // 作成したJSファイルのURIを取得する
    const scriptUri = webviewView.webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "media", "main.js")
    );
    // 作成したCSSファイルのURIを取得する
    const styleUri = webviewView.webview.asWebviewUri(
      vscode.Uri.joinPath(this.extensionUri, "media", "main.css")
    );
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `...`
  }
}

Content Security Policy(CSP)を指定する

今回の拡張機能では特に必要なさそうですが、公式Exampleに倣ってCSPを記述し、読み込まれるリソースを制限します。

https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

<script>に設定するnonce値は公式に従ってランダムな文字列を生成するgetNonce関数を作成してその値を設定します。

src/extension.ts
class MyExtensionViewProvider implements vscode.WebviewViewProvider {
  private _view?: vscode.WebviewView;

  constructor(private readonly extensionUri: vscode.Uri) {}
  ...
  public resolveWebviewView(webviewView: vscode.WebviewView) {
    ...
+   const nonce = getNonce();
    // ここで表示させたいHTMLを記述する
    webviewView.webview.html = `<!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
+       <meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webviewView.webview.cspSource}; style-src ${webviewView.webview.cspSource}; script-src 'nonce-${nonce}';">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
     	<!-- CSSファイルを読み込む -->
        <link rel="stylesheet" href="${styleUri}">
     	<!-- codiconのCSSファイルを読み込む -->
        <link rel="stylesheet" href="${codiconsUri}">
      </head>
      <body>
        <h1>MY EXTENSION</h1>
          <input type="text" id="register-input" />
          <button id="register-button">登録</button>
       	<!-- スクリプトでテキストを挿入するulタグを追加 -->
       	<ul id="list" />
       	<!-- JSファイルを読み込む -->
+       <script nonce="${nonce}" src="${scriptUri}"></script>
-      	<script src="${scriptUri}"></script>
      </body>
      </html>`;
  ...
}

+ 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;
+ }

これで本記事で説明する内容は以上です。お疲れ様でした!🎉

さいごに

VSCode拡張はドキュメントのボリュームがあり、公式のサンプルもかなりたくさんあるので逆にどこから手をつけていいかわからなくなっていたので、同じように困っている人の手助けになれば幸いです。

また、公開した拡張機能は今後もアップデートしていくつもりなので、使ってみて実装してほしい追加機能等あればissueを出していただけるととっても喜びます。(今今はWebviewをReactで開発できないかを模索中。。。💭)

Discussion