今更ながらSPAも試してみようと思い、それなら開発環境もVisualStudioからVSCodeでC#(それでもC#にこだわるんかい!)を実行できるのかなと思ったのですが。
NuGetがめんどくさい。拡張機能は有るけどいっちょ作ってみますか。
ということでVSCodeの拡張機能を調べてみた。
まずはVSCodeと日本語化
VSCodeをマイクロソフトからダウンロードしてインストールします。これは説明要らないですね。
起動すると最初英語になっています。日本語化するには右のアイコンで四角が四つで一つ離れているアイコンが「拡張機能」のアイコンです。マウスを当てると「Extensions」と出ます。クリックすると一覧が出るのですが、上に検索の入力があるので「japanese」と入力し地球儀のアイコンで「日本語」というのをインストールしちゃいます。これでVSCodeを再起動すると日本語になっています。オープンソースでは英語がある程度できる必要があるので、英語のままでもいいと思いますが。(ブラウザでは日本語翻訳があるけれど、どうしても専門用語はちょっと微妙なんですよねー。Angularなんか「角度」でしたっけ、すごい変換してくれるから。)
Node.jsとジェネレータをインストールをインストールして雛形を作成する
拡張機能の開発にはNode.jsをインストールしてジェネレータを取込み、雛形作成を実行すると基本のソースができます。このあたりはExtension APIの「GET STARTED」かVisual Studio Code はじめての拡張機能開発に書かれているのでこれを参考にしてください。
ファイルエクスプローラの右メニューを拡張する
ファイルエクスプローラで.netのプロジェクトファイルを右クリックしたコンテキストメニューに、NuGetの項目を追加します。
最初、TreeViewの拡張かと思ってそのあたりのサンプルを調べていたのですが、エクスプローラの拡張ではなくツリービューの機能追加でしかありませんでした。(VSCodeのExtension APIの「Extension Guides」にはどうもこのあたりの記載がないっぽくって、苦労しました。かじり読みするからですね)
結論から言うと、「package.json」の「contributes」を以下の様にしました。
{
...
"contributes": {
"commands": [
{
"command": "NugetGUIManager.view",
"title": "NuGet Manager",
"enablement": "filesExplorerFocus"
}
],
"menus": {
"explorer/context": [
{
"command": "NugetGUIManager.view",
"when": "resourceExtname == .csproj || resourceExtname == .fsproj || resourceExtname == .vbproj"
}
]
},
"configuration": {
"title": "NugetGUIManager",
"properties": {
"NugetGUIManager.serviceIndexURL": {
"type": "string",
"default": "https://api.nuget.org/v3/index.json",
"description": "nuget.org's service index Location."
}
}
}
},
...
}
「menus」に「explorer/context」を付ければよかったんですね。
サンプルに無いものはリファレンス見ないとわからないですね(サンプルにあったんだろうか?)。拡張機能の動作の起点はリファレンスの「Contribution Points」に記述されており、その中の「menus」に記載がありました。
コンテキストメニューへの追加の条件(に限らずいろいろな起点の条件)は「when」で設定できます。詳細はリファレンスの「When clause contexts」にありますが、今回はプロジェクトファイルに限定するように拡張子が「.csproj, .fsproj, .vbproj」の場合にメニューを表示するようにしています。
「commands」は実行するために必須の様です。試しに外してみるとメニューに出てこなくなりました。しかしながらコマンドパレットに出てくるのが邪魔なので、commandsのwhenに「"enablement": "filesExplorerFocus"」とすることにより、ファイルエクスプローラにフォーカスが無いと使えないようになり、コマンドパレットに出てこなくなりました。このあたりの条件もリファレンスの「When clause contexts」と同じです。
「configuration」はこの拡張機能のパラメータです。これを設定することで、VScodeの設定画面に「NuGetGUIManager」が追加され、「serviceIndexURL」が設定できるようになります。もちろん拡張機能で参照できます。ここではNuGetサーバーのサービスインデックスのURLを設定できるようにしています(変わるかもしれない、もしくは違うところを参照したい場合の為)。
WebViewの追加
GUIを使うということで、WebViewを追加します。これはサンプルが「Extension Guides」にありました。セキュリティー関連の制限や、ファイルパスの取得などがちょっと厄介です。
「Visual Studio Code はじめての拡張機能開発」で書かれているように、実行時のエントリは「extention.ts」内の「activate」にコマンドを登録します。
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import * as vscode from 'vscode';
import * as nugetView from './nugetView';
// アクティベート処理
export function activate(context: vscode.ExtensionContext) {
// NuGet GUI Managerコマンドの登録
let disposable = vscode.commands.registerCommand('NugetGUIManager.view', (target) => {
// NuGet用のビューを構築する
nugetView.openNugetView(context, target.fsPath);
});
context.subscriptions.push(disposable);
}
私の場合は、ビューの構築と管理を「nugetView.ts」に書き込んだのでこのようになっています。「nugetView.ts」の一部を抜粋すると以下の様になっています。
// オープンしているNuGet管理ビューの管理リスト。プロジェクトファイルをキーにしている
let views: { [name: string]: vscode.WebviewPanel } = {};
//====================================================================================================
// NuGet管理ビューのオープン。ビューが作成されていればアクティブにし、無ければ構築する
// context 拡張機能のコンテキスト
// projectFilePath プロジェクトファイルパス
//====================================================================================================
export function openNugetView(context: vscode.ExtensionContext, projectFilePath:string) {
// プロジェクトファイルに関連付けられたNuGet管理ビューの存在確認
if(views[projectFilePath] === undefined)
{
// ビューがなかったので作成する
createNugetView(context, projectFilePath);
}
else{
// 既にあるのでアクティブにする
views[projectFilePath].reveal(vscode.ViewColumn.One);
}
}
//====================================================================================================
// NuGet管理ビューの作成
// context 拡張機能のコンテキスト
// projectFilePath プロジェクトファイルパス
//====================================================================================================
function createNugetView(context: vscode.ExtensionContext, projectFilePath:string)
{
// プロジェクトファイルパスからファイル名の部分を取り出す
let filename = path.basename(projectFilePath);
// NuGet用のWebViewを構築する
const panel = vscode.window.createWebviewPanel(
'NuGetManager',
'NuGet Manager(' + filename + ')',
vscode.ViewColumn.One,
{
enableScripts: true // JavaScriptを有効にする
}
);
// NuGet用のWebViewにコンテンツを設定する
panel.webview.html = getWebviewContent(context, 'view/view.html',{
projectPath:escape(projectFilePath),
scriptPath:getWevUriPath(context, panel, 'view/view.js'),
stylePath:getWevUriPath(context, panel, 'view/view.css'),
axiosPath:getWevUriPath(context, panel, 'node_modules/axios/dist/axios.min.js'),
serviceIndexURL:"" + vscode.workspace.getConfiguration('NugetGUIManager').get('serviceIndexURL')
});
// パネルの表示非表示が変更されたイベントの処理(再表示された時に初期表示に戻るため)
panel.onDidChangeViewState((e) => {
// 非表示→表示になった時にインストール済みのパッケージリストを設定する
if(e.webviewPanel.visible){
setInstalledPackaghes(context, panel, projectFilePath);
}
});
// パネルが破棄された時に管理用のリストから削除するイベント処理を設定する
panel.onDidDispose(() => {
Object.keys(views).forEach(key => {
if(views[key] === panel)
{
delete views[key];
}
});
});
// NuGet管理ビューからの処理要求のハンドラを登録する
setDidReceiveMessage(context, panel);
// 非同期でインストール済みのパッケージリストを設定する
setInstalledPackaghes(context, panel, projectFilePath);
// ビューの管理用リストに追加する
views[projectFilePath] = panel;
}
//====================================================================================================
// NuGet管理ビューのhtmlコンテンツをファイルから取得
// context 拡張機能のコンテキスト
// fileRelativePath htmlファイルの拡張機能の基本フォルダからの相対パス
// replaceValues 置換文字列の(変数名:値)の配列。${変数名} がプレースフォルダとなる(テンプレートリテラルに似せている)
//====================================================================================================
function getWebviewContent(context: vscode.ExtensionContext, fileRelativePath: string, replaceValues:{[key:string]:string}) :string{
// ファイル操作モジュールの追加
var fs = require('fs');
// ファイルからテキストを全て読み込む
const viewBasePath = vscode.Uri.file(
path.join(context.extensionPath, fileRelativePath)
);
var html = fs.readFileSync(viewBasePath.fsPath, 'utf8');
// html内のプレースフォルダを置換する
Object.keys(replaceValues).forEach(key => {
html = replaceAll(html, '\\$\\{' + key + '\\}', replaceValues[key]);
});
// すべて置換したものを返す
return html;
}
//====================================================================================================
// パネル内で有効なURIに変換する
// context 拡張機能のコンテキスト
// panel WebViewのパネル
// relativePath 拡張機能の基本フォルダからの相対パス
//====================================================================================================
function getWevUriPath(context: vscode.ExtensionContext, panel:vscode.WebviewPanel, relativePath: string)
{
// NuGet管理のWebViewで利用するWebView内で利用可能なURIを取得する
const onScriptPath = vscode.Uri.file(
path.join(context.extensionPath, relativePath)
);
return panel.webview.asWebviewUri(onScriptPath).toString();
}
プロジェクトファイルが複数ある可能性があり、それぞれのビューが開く仕様です。その為、ファイルパスとビューの実態を管理する変数で「views」を定義しています。
該当するビューが無い場合、新たに「createNugetView」関数で作成し、すでにある場合はビューの「reveal」でアクティブにします。作成時にオプションで「enableScripts: true」を設定することで、html内でjavascriptが有効になります。これを設定しないとjavascriptが使えません。
WebView内のhtmlを設定する
パネルの「panel.webview.html」に表示したいhtmlを入れるとビューの内容が表示されます。サンプルではテンプレートリテラルを使っていますが、htmlの入力のサポートが受けられないので別ファイルから取り込むようにしてみました(getWebviewContent()参照)。そうするとプレースフォルダが使えないので、同じように使えるように小細工しています。
ローカルファイルの拡張機能内での参照と、htmlからの参照
ローカルのファイルを読み込むためのパスは絶対パス(path.join(context.extensionPath, fileRelativePath)で、コンテキストのベースパスとそこからの相対パスを結合しています)を「vscode.Uri.file()」で置き換える必要があります。
さらに、cssやjsファイルをhtml内で読み込むためには、その変換したファイルパスをさらに「panel.webview.asWebviewUri()」でhtml内から参照できる型に置き換える必要があります。ちなみにテンプレートリテラルのプレースフォルダでは「toString()」が無くとも問題ないのですが(勝手に変換する。この辺りが、場合によって想定外の変換がされてしまうのであまり好きではない。)、ここでは変換して使っています。
拡張機能からビューにメッセージを送る
ビューの作成後、非同期処理でビューにメッセージを送ることができます。
//====================================================================================================
// NuGet管理ビューにインストール済みのパッケージリストを設定する
// context 拡張機能のコンテキスト
// panel NuGet管理ビュー
// projectFilePath プロジェクトファイルパス
//====================================================================================================
function setInstalledPackaghes(context: vscode.ExtensionContext, panel:vscode.WebviewPanel, projectFilePath:string)
{
// プロジェクトファイルからパッケージリストを取得してWebViewに表示する
nuget.getList(projectFilePath).then(packageList => {
// WebViewへのメッセージ送信を利用してパッケージリストを表示す
panel.webview.postMessage({ command: 'setPackageList', serviceIndexURL: "" + vscode.workspace.getConfiguration('NugetGUIManager').get('serviceIndexURL') , list: packageList });
});
}
上の処理はビュー作成時後、非同期でNuGetでプロジェクトのパッケージリストを取得してビューに送信しています。
「panel.webview.postMessage()」がその処理です。ここから以下のhtml内(以下の「view.js」)で利用しているjavascriptの「message」で受け取って一覧を表示させています。
//----------------------------------------------------------------------------------------------------
// 拡張機能からWebViewへのメッセージを処理するハンドラの登録
//----------------------------------------------------------------------------------------------------
window.addEventListener('message', event => {
// イベントからメッセージを取得
const message = event.data;
// メッセージのcommandごとに処理する
switch (message.command) {
case 'setPackageList': // パッケージリストの設定
setInstalledPackageList(message.list);
break;
}
});
htmlから拡張機能の処理を実行する。でもできるのは1回のみ
htmlから拡張機能へはメッセージを送信するには「vscode.postMessage()」(以下の「javascript:view.js」参照)でメッセージを送信します。
//--------------------------------------------------------------------------------
// 選択しているパッケージを追加
//--------------------------------------------------------------------------------
function addPackage(){
// カーソルを時計にする
document.body.style.cursor = 'wait';
// 選択されているバージョンを取得
const versionSelectElement = document.getElementById("findedPackageVersions");
const selectVersion = versionSelectElement.options[ versionSelectElement.selectedIndex].value;
if((selectedFindedPackageIndex >= 0) && (selectVersion !== undefined))
{
// 拡張機能のコンテキストに追加メッセージを送る
const vscode = acquireVsCodeApi();
vscode.postMessage({
projectfile: document.getElementById("projectPath").value,
command: 'add',
package: findedPackageList[selectedFindedPackageIndex].id,
version: selectVersion
});
}
}
拡張機能の処理としてメッセージを受け取るのは「panel.webview.onDidReceiveMessage()」です。(以下の「nugetView.ts」参照)
これで拡張機能のほうからNuGetのコマンドをシェルで実行しているのですが、ここでひとつ問題が。このメッセージを送信できるのはセキュリティーの関係上、同一セッションで1回のみということになっています。そこで苦肉の策として処理実行後、パネルをいったん破棄して再度構築しています。ですので、処理の終了後、一旦画面が消えて再表示されます。
//====================================================================================================
// NuGet管理ビューからの処理要求のハンドラを登録する
// context 拡張機能のコンテキスト
// panel NuGet管理ビュー
//====================================================================================================
async function setDidReceiveMessage(context: vscode.ExtensionContext, panel:vscode.WebviewPanel)
{
// NuGet管理ビューからの処理要求のハンドラを登録
panel.webview.onDidReceiveMessage(
message => {
const projFilePath = unescape( message.projectfile);
if(message.command === 'add')
{
// 追加コマンド
nuget.addPackage(projFilePath, message.package, message.version).then(result =>{
panel.dispose();
createNugetView(context, projFilePath);
});
}
else if(message.command === 'update')
{
// 更新コマンド
nuget.updatePackage(projFilePath, message.package, message.version).then(result =>{
panel.dispose();
createNugetView(context, projFilePath);
});
}
else if(message.command === 'delete')
{
// 削除コマンド
var p = nuget.deletePackage(projFilePath, message.package).then(result =>{
panel.dispose();
createNugetView(context, projFilePath);
});
}
},
undefined,
context.subscriptions
);
}
あとはhtml,css,javascripでがんばれ
後は基本webページの作成と同じです。ちょっと異なるのは「alert()」と「confirm()」が使えない。それと[style]の「height:100hv」が使えません。
ビュー内のjavascriptのデバッグについてはドキュメントのWebViewに記述がありますが、コマンドパレットから「Developper: Open Webview dvelopper tools」を実行してその画面で行います。
拡張機能もソースも公開しています
今回は拡張機能の説明なのでNuGetの処理は割愛しています。
拡張機能もせっかく作成したので「NuGet GUI Manager」という名前で公開してみました。(公開手順の練習です)
ソースも「https://github.com/nosa67/NuGetGUIManager.git 」に公開しておきますので、ご参考にどうぞ。もっとも、javascriptはそんなに使っていないので、見苦しいソースになっているかと思いますが。
あとがき
Qiitaの記事にはこの拡張機能の作成も含めてお世話になっているので、少しでも他者の役に立てればうれしいです。
お粗末様でした。