4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

VSCodeでノートとTodoをまとめて管理できる拡張機能を作ってみた

Posted at

はじめに

VSCodeを使ってmarkdown形式でノートを取るとGitで管理することができて便利です。
私は作業中のタスクに関するメモもVSCodeで書いているのですが、複数のタスクに同時進行で取り組んでいる際にそれぞれのメモファイルを編集するためにいちいちタブを切り替えなければいけないのが面倒になってきました。作業中のタスクに関するメモは単一のファイルにまとめて書いていき、タスクの完了後にはその内容を将来参照できるようにどこかに保存しておけると便利です。
そこで、あるmarkdownファイルのタスクリストから個々のタスクの内容を別のファイルにコピーする拡張機能を作ってみました。この拡張機能を使うことで、同時進行で編集したいすべてのメモを単一のファイルで管理し、書き終わったもの(完了したタスクに関するメモ)から別ファイルに移動させていくことができます。具体的な使い方については以下のGifをご覧ください。
本記事では、作成したExtensionの使い方とVSCode Extensionの作成方法を説明します。

目次

使い方

作成した拡張機能の使い方を説明します。

ノートの書き方

メモを取る際には、以下のようにGithub FlavoredのmarkdownのTask List(チェックボックス)形式で、異なる内容のメモをそれぞれ書いていきます。

- [ ] メモ1
  メモ1の内容...
- [ ] メモ2
  メモ2の内容...

書き終わったメモを別ファイルにコピーする

Complete Todo and Copy to Notes というコマンド、あるいは ctrl+d, cmd+d というショートカットキーを使うことで、カーソル位置のタスクの内容が別ファイルに移動します。

complete-todo-explanation.gif

メモにmetadataを付与する

以下のような記法でメモにmetadataを追加することができます。このメタデータはmarkdownをRenderした際には表示されません。

- [ ] メモ3

  [metadata]: # (YOUR_METADATA_NAME: YOUR_METADATA_VALUE)
  メモ3の内容...

このmetadataの記法は Reference-style Links と呼ばれるmarkdownのsyntaxを利用しています。Reference-style Links ではURIを定義する部分とそれを参照する部分とにわけてリンクを書くことができます。このうち定義部分だけを書くとRender結果には何も影響を与えなくなるため、定義部分を使ってRender時には表示されない文字列を埋め込むことができます。詳しくは以下の記事などを参照してください。

メタデータはコピー後のメモファイルでは、Yaml Front Matterという形式に変換されます。CreatedDateなど一部の特殊なメタデータは自動で追加されます。

メモ3.md
---
CreatedDate: 2022-10-29T11:47:10+0900
AppendMode: null
FileName: null
Title: test
FolderPath: null
Tags: []
YOUR_METADATA_NAME: YOUR_METADATA_VALUE
---

# test

メモ 3 の内容...

Yaml Front MatterはmarkdownのRender後には表示されませんが、GitHubで開くと以下のようにテーブル形式で表示されます。
image.png

メモのコピー先のPathを変更する

メモの中に FolderPath というmetadataを埋め込むことで、メモがコピーされる先のpathを変更することができます。同様にTitleやFileNameというmetadataを使って、コピー後のノートのタイトルやファイル名を変更することができます。

- [ ] メモ3

  [metadata]: # (FolderPath: path/to/destination/directory )
  [metadata]: # (Title: "Title of the memo")
  [metadata]: # (FileName: "Filename of the memo.md" )

metadata-explanation.gif

メモにタグをつける

以下のようにTagsというmetadataをメモの中のどこかに書いておくことで、コピー後のメモにタグをつけて管理することができるようになります。

- [ ] メモ4

  [metadata]: # (Tags: [Java, Qiita, Github])

タグはコピー後のメモでは、Yaml Front Matter形式で保存されるため、Render時には表示されません。

---
Tags:
  - Java
  - Qiita
  - Github
CreatedDate: 2022-10-29T01:20:34+0900
---
# メモ4
メモの内容...

NOTES TAGS というツリーメニューにタグの一覧が表示され、それぞれのタグを含んでいるドキュメントを開くことができます。また、タグ名の右端の"目"のアイコンをクリックすることで、そのタグを含んでいるドキュメントすべてを結合した新しいドキュメントを生成することができます。
tag-tree-explanation.gif

Extension公開までの流れ

1. Extensionの雛形の作成

こちらの手順に従って、extensionの雛形を作成することができます。

#  Yeoman と VS Code Extension Generator をインストールする
npm install -g yo generator-code

# 対話形式でextensionの名前などを設定する
yo code

2. package.jsonの編集

package.jsonを編集してextensionの名前やmarketplaceで表示されるcategoryやiconなどを変更します。activationEventsを変更することでextensionがactivateされる条件を変更できます。例えば、"特定の言語のファイルが開かれた時"や"特定のコマンドが実行された時"などを選ぶことができます。
本Extensionではmarkdownで書かれているファイルが開かれたときにactivateされるように、以下のように設定しました。

package.json
  "activationEvents": [
    "onLanguage:markdown"
  ]

package.jsonで設定できる項目の詳細は以下のページを参照してください。

本extensionで変更した主な項目を以下のテーブルにまとめます。contributesについては2.1節でもう少し詳しく説明します。

名前 説明
name extensionの名前
version extensionのバージョン
engines サポートするvscodeのバージョン
categories extensionのカテゴリ。LintersやThemesなど
keywords MarketPlaceでextensionの説明ページに表示されるタグ
activationEvents Extensionをactivateする条件
contributes extensionで実装するcommandやショートカットキーなどを登録。(2.1節を参照)
icon extensionのiconとして表示される画像へのpath

2.1 contributesの設定

以下のページを参考にpackage.jsonの contributes fieldに実装するcommandやmenuなどを追加していきます。
https://code.visualstudio.com/api/references/contribution-points
今回のextensionで設定した主なcontributionは以下の通りです。(一部省略してあります)

packagee.json
 "contributes": {
    // commandを追加
    "commands": [
      {
        "command": "todo-notes.completeAndCopyTodo",
        "title": "Complete Todo and Copy to Notes"
      },
      {
        "command": "todoNotesTags.refreshEntry",
        "title": "Refresh",
        // iconを設定 iconの一覧ページ: https://code.visualstudio.com/api/references/icons-in-labels
        "icon": "$(refresh)"
      },
      {
        "command": "todoNotesTags.createVirtualDocument",
        "title": "See all documents in this tag",
        "icon": "$(eye)"
      }
    ],
    "menus": {
      // editor上で右クリックした際に表示されるmenuにコマンドを追加
      "editor/context": [
        {
          "when": "resourceLangId == markdown",
          "command": "todo-notes.addTodo",
          "group": "1_modification"
        },
        {
          "when": "resourceLangId == markdown",
          "command": "todo-notes.completeAndCopyTodo",
          "group": "1_modification"
        }
      ],
      // Tree Viewの右上に更新ボタンを追加
      "view/title": [
        {
          "command": "todoNotesTags.refreshEntry",
          "when": "view == todoNotesTags",
          "group": "navigation"
        }
      ],
      // Tree ViewでTag名の右端に仮想ドキュメントを作成するボタンを追加 
      "view/item/context": [
        {
          "command": "todoNotesTags.createVirtualDocument",
          "when": "view == todoNotesTags && viewItem == tag",
          "group": "inline",
          "args": "test"
        }
      ]
    },
    "keybindings": [
      // ショートカットキーを登録
      {
        "command": "todo-notes.completeAndCopyTodo",
        "key": "ctrl+d",
        "mac": "cmd+d",
        "when": "resourceLangId == markdown && editorTextFocus"
      }
    ],
    "configuration": {
      // extensionで使用するconfigの追加
      "title": "todo-notes",
      "properties": {
        "todoNotes.saveNotesPath": {
          "type": "string",
          "default": "notes",
          "description": "Path to directory where notes will be saved."
        },
        "todoNotes.dateFormat": {
          "type": "string",
          "default": "yyyy-mm-dd'T'HH:MM:sso",
          "description": "Date format used in metaata. For example: yyyy-mm-dd'T'HH:MM:sso"
        }
      }
    },
    "views": {
      // Tree Viewの追加 (実装は3.2節参照)
      "explorer": [
        {
          "id": "todoNotesTags",
          "name": "Notes Tags"
        }
      ]
    }
  },

3. 機能の実装

exteiontion.ts を拡張していきます。雛形を作成した時点では、activateという関数の中でhelloWorldコマンドを登録するコードになっています。このactivate関数は、extensionがactivateされたタイミングで一度だけ実行されます。どういったタイミングでactivateされるのかはpackage.jsonのactivationEventsで設定します。

3.1 コマンドの登録

package.jsonで定義したコマンドの実装をregisterCommandメソッドで登録します。第一引数はpackage.jsonのcontributes.commands.command で指定した名前と同一である必要があります。registerCommandメソッドの戻り値をsubscriptionsに追加しておくことでExtensionがunloadされる際にゴミが残らないようにできるようです。

例えば以下のスニペットではエディターに- [ ] という文字列を追加するaddTodoというコマンドを実装しています。

extension.ts
  const addTodoDisposable = vscode.commands.registerCommand("todo-notes.addTodo", () => {
    addTodo();
  });
  context.subscriptions.push(addTodoDisposable);
export function addTodo() {
  const editor = vscode.window.activeTextEditor;
  if (!editor) {
    return;
  }
  const insertStr = `- [ ] `;

  editor.edit((e) => {
    e.insert(editor.selection.active, insertStr);
  });
}

3.2 TreeViewの作成

画像左下のNOTES TAGS という部分にはツリー形式でtagの一覧とそれぞれのtagが付いているdocumentが表示されます。このようにツリー形式でアイテムを表示するUIエレメントはTreeViewと呼ばれています。TreeViewの作り方についてはこちらの公式ドキュメントにわかりやすく書かれています。

image.png

まずpackage.jsonのcontributes.viewsでviewを追加することを宣言します。

package.json
  "contributes": {
    ...
    "views": {
      "explorer": [
        {
          "id": "todoNotesTags",
          "name": "Notes Tags"
        }
      ]
    }
  }

続いて、Tree Viewに表示するエレメントを提供するクラスを実装していきます。 このクラスはvscode.TreeDataProvider<T>を継承し、以下の2つのメソッドの実装を提供する必要があります。Tree ViewがUIに表示される際には、まずgetChildrenメソッドが引数なしで呼ばれ、さらにその戻り値の1つ1つに対してgetChildrenが再起的に呼ばれます。

  • getChildren(element?: T): ProviderResult<T[]> 引数で渡されるエレメントの子供エレメントの一覧を返すようなメソッドです。引数が渡されなかった場合には、root要素(根のエレメント)の一覧を返すようにします。
  • getTreeItem(element: T): TreeItem | Thenable<TreeItem> 引数で渡されたエレメントをUIで表示するためにTreeItem型に変換して返します。

今回のExtensionでは以下のように実装しました。(一部省略)

export class NotesTagsProvider implements vscode.TreeDataProvider<Element> {
  tagToElements: { [key: string]: Element[] };
  client: LanguageClient;
  constructor(private workspaceRoot: string, client: LanguageClient) {
    this.tagToElements = {};
    this.client = client;
  }
    
  // ElementはTreeItemを継承しているのでそのまま返す
  getTreeItem(element: Element): vscode.TreeItem {
    return element;
  }

  getChildren(element?: Element): Thenable<Element[]> {
    if (this.workspaceRoot == null) {
      return Promise.resolve([]);
    }

    if (element != null) {
      // 子供エレメントのArrayを返します。
      if (element.name in this.tagToElements) {
        return Promise.resolve(this.tagToElements[element.name].sort());
      } else {
        return Promise.resolve([]);
      }
    } else {
      // root level
      return this.callLanguageServerForTagTree();
    }
  }

  // タグとファイルの組み合わせをLanguage Serverから取得
  async callLanguageServerForTagTree(): Promise<Element[]> {
    // Language Serverにリクエストを送信
    const res: { tag: string; files: { name: string; filePath: string }[] }[] = await this.client
      .onReady()
      .then(() => this.client.sendRequest(GET_ALL_TAGS_METHOD));

    this.tagToElements = {};
    res.forEach((val) => (this.tagToElements[val.tag] = val.files.map((file) => new Element("file", file.name, file.filePath))));
    return res.map((val) => new Element("tag", val.tag, null));
  }
}
export class Element extends vscode.TreeItem {
  type: "tag" | "file";
  name: string;
  filePath: string | null;
  constructor(type: "tag" | "file", name: string, filePath: string | null) {
    // tagの場合には子供を折りたたんだ状態で表示
    const collapsibleState: vscode.TreeItemCollapsibleState = type === "tag" ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
    // 表示されるElementのlabelを設定
    super(name, collapsibleState);
    this.type = type;
    this.name = name;
    this.filePath = filePath;
    this.contextValue = type;

    // iconを設定
    if (type === "tag") {
      this.iconPath = new vscode.ThemeIcon("tag");
    } else {
      this.iconPath = new vscode.ThemeIcon("file");
    }
  }
}

3.3 DocumentProviderの作成

本Extensionでは、Tree ViewでTag名の横の"目"のiconのボタンを押すことで、そのTagを持つドキュメントを全て結合した新しいドキュメント(ReadonlyなVirtual Document)を表示する機能を実装しました。具体的な使用例はこちら(メモにタグをつける)のGifを参照ください。この機能はTextDocumentContentProviderというinterfaceを実装したクラスを用いて作成できます。このinterfaceで定義されているメソッドはonDidChangeprovideTextDocumentContentの2つがありますが、このうちprovideTextDocumentContentだけを実装すれば十分です。
このメソッドのsignatureは以下の通りです。特定のURIを引数として受け取り、そのURIに対応するドキュメントの文字列を生成して返します。例えば、本extensionではtags:タグAというようにschemeはtagsでpathにタグ名をいれたURIを受け取って、そのtagが付いている全てのdocumentの内容を結合した文字列を返すように実装しました。

provideTextDocumentContent(uri: Uri, token: CancellationToken): ProviderResult<string>;

TextDocumentContentProviderの詳細な実装内容は省略します。実装ができたらそのインスタンスをschemeと紐づけて登録します。

extension.ts
const tagAllDocumentProvider = new (class implements vscode.TextDocumentContentProvider {
  provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
    // uriから文字列を生成する。実装は省略。
    return provider.callLanguageServerForVirtualDocument(uri, vscode?.workspace?.workspaceFolders ? vscode?.workspace?.workspaceFolders[0].uri.path : null);
  }
})();
// "tags" というschemeのuriが開かれた際にtagAllDocumentProviderが使われるように登録する
const tagAllDocumentProviderDisposable = vscode.workspace.registerTextDocumentContentProvider("tags", tagAllDocumentProvider);
context.subscriptions.push(tagAllDocumentProviderDisposable);

つづいて、TreeViewのtag名の横に"目"のiconのボタンを追加し、そのボタンを押した際に上記のTextDocumentProviderが使われるようにコマンド実装していきます。
まず、package.jsonでボタンの追加とコマンドの定義をします。

packagee.json
 "contributes": {
    // commandを追加
    "commands": [
      ...
      {
        // virtual documentを生成するためのコマンドを定義
        "command": "todoNotesTags.createVirtualDocument",
        "title": "See all documents in this tag",
        // iconを設定
        "icon": "$(eye)"
      }
    ],
    "menus": {
      "view/item/context": [
        {
          // クリックされた際には上で定義したコマンドを呼び出す
          "command": "todoNotesTags.createVirtualDocument",
          // Extensionで実装したTreeViewでのみ、かつtagの場合にのみ有効にする
          "when": "view == todoNotesTags && viewItem == tag",
          // ボタンが追加される場所を指定
          "group": "inline"
        }
      ]
    },
  },

extension.tsでコマンドを実装します。

extension.ts
const createVirtualDocumentDisposable = vscode.commands.registerCommand("todoNotesTags.createVirtualDocument", async (element: Element) => {
  // 引数でTreeViewの中でボタンがクリックされたエレメントが渡されるので、そこからタグ名を取り出しURIを生成
  const uri = vscode.Uri.parse("tags:" + element.name);
  // ドキュメントを生成する。schmeが位置するtagAllDocumentProviderが使われる
  const doc = await vscode.workspace.openTextDocument(uri);
  // ドキュメントの言語としてmarkdownを指定する
  await vscode.languages.setTextDocumentLanguage(doc, "markdown");
 // ドキュメントを開いて表示する
  await vscode.window.showTextDocument(doc, { preview: true });
});

以上で、TreeViewでタグ名の横のボタンを押したときに、そのタグを持っているドキュメントを結合した新しいドキュメントが生成される機能が実装できました。

TextDocumentContentProviderの詳細については以下のページを参照ください。

3.4 Language Serverの実装

本ExtensionではTagのTreeViewを表示するために保存済みのすべてのmarkdownファイルのYAML Front Matterをparseして、そのファイルに付いているTagを取得しています。ファイル数が増えるにつれて処理負荷が増大していき、VSCode本体のパフォーマンスに影響を及ぼしてしまうことが予想されます。こういった重い処理はLanguage Serverとして実装することで、別プロセスで実行されパフォーマンスの劣化を防ぐことができます。Language ServerはVSCode本体側のプロセスとLanguage Server Protocolというプロトコルで通信するため、Language Serverは任意の言語で実装することができます。今回はTypeScriptを使って実装しました。

詳細はこちらのガイドを参照してください。

まずClient側(VSCode本体側)でconnectionの設定と起動を行います。今回は概ね上記のガイドの通りに設定しました。

extension.ts
let client: LanguageClient;

export function activate(context: vscode.ExtensionContext) {
  const serverModule = context.asAbsolutePath(path.join("out", "server", "server.js"));
  const debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] };
  const serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: debugOptions,
    },
  };
  // Options to control the language client
  const clientOptions: LanguageClientOptions = {
    documentSelector: [{ scheme: "file", language: "markdown" }],
  };
  // Create the language client and start the client.
  client = new LanguageClient("todoNotesLangaugeServer", "Todo Notes Language Server", serverOptions, clientOptions);
  // Start the client. This will also launch the server
  client.start();

...
}
// this method is called when your extension is deactivated
export function deactivate() {
  if (!client) {
    return undefined;
  }
  return client.stop();
}

各ファイルに含まれているtagの情報が必要になった際にはclient側(VSCode本体側)からRequestを送ります。ここで、"getAllTags"はRequestを識別するためのIDで、Server側で同じIDに対する処理を実装しておく必要があります。

client側
async callLanguageServerForTagTree(): Promise<Element[]> {
    const res: { tag: string; files: { name: string; filePath: string }[] }[] = await this.client
      // clientとサーバーの接続が確立されてから実行されるように、onReady()を挟みます
      .onReady()
      .then(() => this.client.sendRequest("getAllTags"));
    
    this.tagToElements = {};
    res.forEach((val) => (this.tagToElements[val.tag] = val.files.map((file) => new Element("file", file.name, file.filePath))));
    return res.map((val) => new Element("tag", val.tag, null));
}

対応するServer側の実装はこのようにします。sendRequestsendNotificationの第二引数に指定することで任意の型のparameterを送ることができます。Server側からTag情報の変更を通知する際には、tagとファイルの対応情報をparameterに入れてClient側に送信しています。

server.ts
//コネクションを作成
const connection = createConnection(ProposedFeatures.all);
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);

let hasWorkspaceFolderCapability = false;
let tagHandler: TagHandler;

connection.onInitialize((params: InitializeParams) => {
  const capabilities = params.capabilities;
  const workspaceRoot =
    params.workspaceFolders && params.workspaceFolders[0].uri.startsWith("file://") ? params.workspaceFolders[0].uri.substring("file://".length) : "";
  // documentからtag情報を取得する処理などを実装したクラス(中身は省略)
  tagHandler = new TagHandler(workspaceRoot);

  hasWorkspaceFolderCapability = !!(capabilities.workspace && !!capabilities.workspace.workspaceFolders);

  const result: InitializeResult = {
    capabilities: {
      textDocumentSync: TextDocumentSyncKind.Incremental
    },
  };
  if (hasWorkspaceFolderCapability) {
    result.capabilities.workspace = {
      workspaceFolders: {
        supported: true,
      },
    };
  }
  return result;
});
 
// getAllTagsというIDのRequestが来た際には、全てのドキュメントに含まれるtagの一覧を取得して返す。
connection.onRequest("getAllTags", () => {
  // tagHandlerの実装は省略します。
  return tagHandler.getAllTags();
});

// documentがSaveされた際にそのドキュメントに含まれるタグの情報を取得し更新があった場合にはClientに通知する
documents.onDidSave((change) => {
  const tagsToElement = tagHandler.handleSavedFile(change.document.uri, change.document.languageId, change.document.version);
  if (tagsToElement) {
    const params: RefreshTagsTreeParams = { tagsToElement: tagsToElement };
    // Server側からClient側(VSCode本体側)に対してTagの変更を通知
    connection.sendNotification(REFRESH_TAGS_TREE_METHOD, params);
  }
});

// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);

// Listen on the connection
connection.listen();

4. 動作確認

Yeoman と VS Code Extension Generatorを使ってExtensionの雛形を作ると、F5キーを押すだけで開発中のExtensionの動作確認を簡単に行えるように初めから設定されています。F5キーを押すと、開発中のextensionがインストールされた状態のVSCodeインスタンスが、開発に使っているインスタンスとは別で立ち上がります。
次のスクリーンショットのように、Run and DebugからRun Extensionを実行しても同じようにテスト用のインスタンスが立ち上がります。
image.png

Breakpointポイントを設定しておき、テストインスタンス側がそのコードに到達したタイミングで実行を一時停止させることができます。また、Extensionのコード内でconsole.log()で出力した文字列をチェックすることもできます。次のスクリーンショットのようにテストインスタンス側でHelp > Toggle Developer Toolsをクリックすることでブラウザと同様の開発者ツールが立ち上がり、Consoleに出力された文字列を見ることができます。
image.png

Language Server側のconsole logはDeveloper toolには表示されません。次のスクリーンショットのようにテストインスタンス側のOutputウィンドウで、ドロップダウンメニューからLanguage Serverの名前を選択すると表示されます。
image.png

5. 公開

Extensionが完成したら以下のページの手順で公開することができます。

まずはこちらの手順に従ってAzure DevOpsのAccountとPersonal Access Tokenを作成します。
https://code.visualstudio.com/api/working-with-extensions/publishing-extension#get-a-personal-access-token

つづいてvsceをインストールします。

npm install -g vsce

package.jsonに記入したpublisher nameを指定してloginします。先ほど作成したAccess Tokenの入力を求められます。

vsce login <publisher name>
vsce publish

最後にpublishコマンドでpublishします。

vsce publish

vsce publish patch/minor/majorコマンドを使うと自動的にpackage.jsonのversion番号をincrementしつつpublishすることができます。

vsce publish patch

Extension公開後

Extensionを公開するとmarketplaceのpublisherページで、marketplaceでの閲覧数やダウンロード数、VSCodeからのインストール数を確認することができます。
image.png
Marketplaceでダウンロード数として表示されるのは、VSCodeからのインストール数のみのようです。
image.png

最後に

以上でExtensionの使い方と作り方の説明を終わります。

説明に使ったスニペットはスペースの都合上省略した部分が多いので、詳しい実装はこちらのRepoで公開されているソースコードを参照ください。

Extensionはこちらのページからインストールできるのでよかったら使ってみてください。

4
3
0

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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?