Visual Studio出力ウィンドウ右クリックに独自コマンドを追加するVSIX実装ガイド

Visual Studio 2022 の「出力」ウィンドウを右クリックしたときに、自作ツール用のメニューを追加したい──そう思って試してみると、DocumentContextMenu にぶら下げても全く出てこない…というハマり方をしがちです。本記事では、出力ウィンドウが属している本当のコンテキストメニュー ID と、VSIX(拡張機能)での実装手順を、最小構成の .vsct と C# コード付きで詳しく解説します。

目次

Visual Studio の「出力」ウィンドウに独自コマンドを追加したい

まず今回のゴールを整理します。

  • 対象:Visual Studio 2022(VSIX 拡張機能)
  • 場所:「出力 (Output)」ウィンドウの右クリック(コンテキスト)メニュー
  • やりたいこと:
    • 選択したテキストを元に独自処理を実行するメニュー項目を追加したい
    • できれば「出力」ウィンドウ以外では表示したくない

このとき、多くの人が最初に試すのが、Command Explorer で DocumentWindowCommandSet / DocumentContextMenu を探して .vsct に設定する方法です。しかし、これはエディタ(ドキュメント)用のコンテキストメニューであり、「出力」ウィンドウには適用されません。

答えから言うと、「出力」ウィンドウの右クリックメニューは IDM_VS_CTXT_RESULTSLIST にぶら下がっています。つまり、.vsct での親メニュー指定を

  • Document 系メニュー → ×
  • guidSHLMainMenu / IDM_VS_CTXT_RESULTSLIST → ○

とすることで、はじめて「出力」ウィンドウのコンテキストメニューに自作コマンドを表示できます。

なぜ DocumentContextMenu ではダメなのか

まず、Visual Studio の「コンテキストメニュー」がどう整理されているのかを軽く押さえておきます。

コンテキスト種別代表的な ID代表的な対象出力ウィンドウとの関係
ドキュメントエディタ系DocumentContextMenu(Command Explorer 上)C# / C++ / XAML などのエディタ出力ウィンドウとは別物
結果一覧(Results List)系IDM_VS_CTXT_RESULTSLIST出力ウィンドウ、エラー一覧など出力ウィンドウはここ
ソリューションエクスプローラー系IDM_VS_CTXT_SOLNNODE などプロジェクト / ファイルノード今回は関係なし

Command Explorer 上で見ると「ドキュメントのコンテキストメニュー」がそれらしく見えますが、出力ウィンドウは Text Editor ではなく別カテゴリの「結果系ウィンドウ」扱いです。Visual Studio SDK の vsshlids.h には、こうしたコンテキストメニュー用の ID が IDM_VS_CTXT_* という形で多数定義されており、その中の一つが IDM_VS_CTXT_RESULTSLIST です。

従って、.vsct で「親メニュー」を Document 系にしてしまうと、出力ウィンドウとはそもそもコンテキストが違うため、どんなに正しくコマンドを定義しても一切表示されません。

最小構成の .vsct で「出力」ウィンドウにメニューを追加する

ここからは実際の .vsct の最小構成例を元に、どこをどう書けばよいかを分解して解説します。

.vsct の全体イメージ

まずは、出力ウィンドウ右クリックに「選択テキストで処理する」というメニューを1つだけ追加する、最小構成の .vsct 例です。

<?xml version="1.0" encoding="utf-8"?>
<CommandTable xmlns="http://schemas.microsoft.com/VisualStudio/2005-10-18/CommandTable">

  <Extern href="stdidcmd.h" />
  <Extern href="vsshlids.h" />

  <Commands package="guidMyPackage">

    <Groups>
      <!-- 出力ウィンドウの右クリックに載せるグループ -->
      <Group guid="guidMyCmdSet" id="MyMenuGroup" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_RESULTSLIST" />
      </Group>
    </Groups>

    <Buttons>
      <Button guid="guidMyCmdSet" id="Command1Id" priority="0x0100" type="Button">
        <Parent guid="guidMyCmdSet" id="MyMenuGroup" />
        <Strings>
          <ButtonText>選択テキストで処理する</ButtonText>
        </Strings>
      </Button>
    </Buttons>

  </Commands>

  <Symbols>
    <GuidSymbol name="guidMyPackage" value="{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" />

    <GuidSymbol name="guidMyCmdSet" value="{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}">
      <IDSymbol name="MyMenuGroup" value="0x1020" />
      <IDSymbol name="Command1Id" value="0x0100" />
    </GuidSymbol>

  </Symbols>
</CommandTable>

この中で特に重要なのは、次の2点です。

  • <Extern href="vsshlids.h" /> を追加していること
  • <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_RESULTSLIST" /> を使っていること

guidSHLMainMenu と IDM_VS_CTXT_RESULTSLIST は Visual Studio 側で定義されている GUID / ID なので、自分で値を考える必要はありません。Extern で vsshlids.h を読み込むことで、これらのシンボルが解決される仕組みになっています。

.vsct の各要素の意味

要素役割今回のポイント
<Extern>Visual Studio 標準のコマンド ID を参照するvsshlids.h を参照することで IDM_VS_CTXT_RESULTSLIST が使える
<Group>複数のコマンドをまとめて配置する「グループ」Parent でどのメニューにぶら下がるかを指定
Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_RESULTSLIST"親メニューの指定ここが「出力」ウィンドウ右クリックのコンテキストに対応する
<Button>実際のコマンド(メニュー項目)を定義表示名(ButtonText)やグループを指定
<Symbols>自分の GUID / ID を定義GUID は VSIX マニフェストのものと揃える

AsyncPackage 側の準備:ProvideMenuResource とコマンド登録

.vsct を書いただけではコマンドは動きません。VSIX プロジェクト側で AsyncPackage にいくつか設定を行い、コマンドの実装と紐付ける必要があります。

AsyncPackage に属性を付与する

拡張機能側のパッケージクラス(通常 MyPackage など)に、最低限次のような属性を付与します。

[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[InstalledProductRegistration("My Extension", "Sample command on Output window", "1.0")]
[ProvideMenuResource("Menus.ctmenu", 1)]
[Guid("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")]
public sealed class MyPackage : AsyncPackage
{
}

ここで重要なのは [ProvideMenuResource("Menus.ctmenu", 1)] です。これがないと、せっかく作成した .vsct が読み込まれず、メニュー項目自体が登録されません。

InitializeAsync でコマンドを登録する

次に、InitializeAsync で OleMenuCommandService を取得し、コマンドを登録します。

protected override async Task InitializeAsync(
    CancellationToken cancellationToken,
    IProgress&lt;ServiceProgressData&gt; progress)
{
    await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);

    if (await GetServiceAsync(typeof(IMenuCommandService)) is OleMenuCommandService mcs)
    {
        var cmdSet = new Guid("YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY"); // guidMyCmdSet
        var cmdId  = new CommandID(cmdSet, 0x0100);                     // Command1Id

        var menuItem = new OleMenuCommand(ExecuteCommand, cmdId);
        menuItem.BeforeQueryStatus += UpdateCommandStatus;
        mcs.AddCommand(menuItem);
    }
}

ここで使っている GUID と ID は、.vsct の GuidSymbol / IDSymbol に定義したものと一致させる必要があります。

  • guidMyCmdSet → C# 側の cmdSet
  • Command1Id (0x0100) → C# 側の CommandID の ID 部分

「出力」ウィンドウの選択テキストを取得する

今回の主目的は「選択したテキストで処理する」ことなので、出力ウィンドウの選択文字列を取得する必要があります。出力ウィンドウは内部的には IVsTextView として扱うことができるため、IVsTextManager からアクティブビューを取得し、GetSelectedText を呼び出せば簡単に取得できます。

using Microsoft.VisualStudio.TextManager.Interop;
using Microsoft.VisualStudio.Shell;
using Task = System.Threading.Tasks.Task;

private async Task&lt;string?&gt; GetSelectedTextAsync()
{
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

    var textMgr = await GetServiceAsync(typeof(SVsTextManager)) as IVsTextManager;
    if (textMgr is null)
    {
        return null;
    }

    textMgr.GetActiveView(fMustHaveFocus: 1, pBuffer: null, ppView: out IVsTextView view);
    if (view is null)
    {
        return null;
    }

    view.GetSelectedText(out var selected);
    return string.IsNullOrEmpty(selected) ? null : selected;
}

上記は AsyncPackage のメソッドとして実装しているイメージです。ExecuteCommand(メニュー選択時のハンドラ)から呼び出して使います。

private async void ExecuteCommand(object sender, EventArgs e)
{
    try
    {
        var selected = await GetSelectedTextAsync();
        if (string.IsNullOrEmpty(selected))
        {
            await VS.MessageBox.ShowAsync("情報",
                "出力ウィンドウでテキストが選択されていません。");
            return;
        }

        // ここで好きな処理を行う
        // 例: 外部ツールに渡す、正規表現で解析する、など
        await VS.MessageBox.ShowAsync("選択テキスト", selected);
    }
    catch (Exception ex)
    {
        await VS.MessageBox.ShowAsync("エラー", ex.ToString());
    }
}

このように、出力ウィンドウがアクティブな状態で右クリックメニューからコマンドを実行すれば、選択テキストを簡単に取得して任意の処理に渡すことができます。

「出力」ウィンドウのときだけメニューを表示したい

ここまでの実装だと、同じ IDM_VS_CTXT_RESULTSLIST を使っている他のウィンドウ(例:エラー一覧など)でもメニューが表示されてしまいます。場合によってはそれでも問題ありませんが、「出力ウィンドウだけに絞りたい」ケースもよくあります。

その場合は、BeforeQueryStatus(OleMenuCommand.BeforeQueryStatus)イベントで、アクティブなツールウィンドウを判定し、不要なときは Visible = false にする方法がシンプルです。

BeforeQueryStatus で可視性を制御する例

using Microsoft.VisualStudio.Shell.Interop;

private void UpdateCommandStatus(object sender, EventArgs e)
{
    ThreadHelper.ThrowIfNotOnUIThread();

    if (sender is not OleMenuCommand cmd)
    {
        return;
    }

    var shell = GetService(typeof(SVsUIShell)) as IVsUIShell;
    if (shell is null)
    {
        cmd.Visible = false;
        return;
    }

    shell.GetCurrentActiveToolWindow(out var frame);
    if (frame is null)
    {
        cmd.Visible = false;
        return;
    }

    // ツールウィンドウの GUID を取得
    frame.GetGuidProperty(
        (int)__VSFPROPID.VSFPROPID_GuidPersistenceSlot,
        out var guid);

    // 出力ウィンドウの GUID(固定値)
    var outputGuid = new Guid(ToolWindowGuids.Outputwindow);

    cmd.Visible = guid == outputGuid;
    cmd.Enabled = cmd.Visible;
}

こうすることで、エラー一覧など他の Results List 系ウィンドウではメニューを非表示にし、出力ウィンドウがアクティブなときだけ表示・有効化されるようになります。

コンテキストメニュー ID の調べ方とよくある勘違い

最後に、「なぜ IDM_VS_CTXT_RESULTSLIST と分かるのか?」という点についても触れておきます。これは主に次の2つのアプローチで確認できます。

Command Explorer を使う場合

  • Visual Studio 付属の Command Explorer(拡張機能など)で、出力ウィンドウを右クリックした状態からメニューを特定する
  • このとき「DocumentWindowCommandSet / DocumentContextMenu」ではなく、Results List 系のメニューに紐付いていることを確認できる

Command Explorer では GUID / ID の組み合わせがそのまま表示されるので、そこから guidSHLMainMenu / IDM_VS_CTXT_RESULTSLIST といった情報を辿ることができます。

vsshlids.h を確認する場合

  • Visual Studio SDK には vsshlids.h が含まれており、そこに IDM_VS_CTXT_* が大量に定義されています
  • この中に #define IDM_VS_CTXT_RESULTSLIST といった形で定義が存在します

「IDM_VS_CTXT_OUTPUTWINDOW という ID は存在しないのか?」と探したくなりますが、その名前の ID は定義されていません。出力ウィンドウはあくまで「Results List ファミリー」の一員として扱われている、という理解がポイントです。

よくあるハマりポイントまとめ

現象ありがちな原因確認ポイント
メニューがまったく表示されない親メニューが Document 系になっている / ProvideMenuResource がないguidSHLMainMenu × IDM_VS_CTXT_RESULTSLIST を使っているか
アイコン・テキストが出ない.vsct の <Buttons> に正しく定義されていないguidMyCmdSet と ID が C# 側と一致しているか
クリックしても何も起きないInitializeAsync でコマンド登録していないOleMenuCommandService.AddCommand を呼んでいるか
出力ウィンドウ以外にもメニューが出る可視性制御をしていないBeforeQueryStatus でツールウィンドウ GUID を判定しているか

実用例:出力ログからチケット番号・URL を抽出する

ここまでで基礎的なところは一通り揃ったので、少しだけ実用的な使い方の例も紹介しておきます。例えば、ビルドログや CI ログにチケット番号や URL が頻繁に出てくる状況を想像してみてください。

  • ログの一部を選択
  • 右クリック → 「チケット番号を開く」メニュー
  • 選択テキストからチケット番号を正規表現で抽出し、Issue トラッカーをブラウザで開く

といったことは、先ほどの GetSelectedTextAsync を起点に簡単に実装できます。イメージとしてはこんな感じです。

private static readonly Regex TicketRegex =
    new Regex(@"TICKET-(\d+)", RegexOptions.Compiled);

private async void ExecuteCommand(object sender, EventArgs e)
{
    var selected = await GetSelectedTextAsync();
    if (string.IsNullOrEmpty(selected))
    {
        await VS.MessageBox.ShowAsync("情報", "テキストを選択してください。");
        return;
    }

    var match = TicketRegex.Match(selected);
    if (!match.Success)
    {
        await VS.MessageBox.ShowAsync("情報", "チケット番号が見つかりません。");
        return;
    }

    var ticketNo = match.Value;
    var url = $"https://example.com/issues/{ticketNo}";

    System.Diagnostics.Process.Start(new ProcessStartInfo
    {
        FileName = url,
        UseShellExecute = true
    });
}

このように、出力ウィンドウを単なる「ログの表示場所」としてではなく、「右クリック1つで外部の仕組みと連携するローコード的 UI」として活用できるのが、コンテキストメニュー拡張の大きなメリットです。

全体の流れをおさらい

ここまでの内容を、実装作業のステップとして整理し直してみます。

ステップ内容ポイント
1VSIX プロジェクトを作成AsyncPackage をベースにする
2.vsct を用意Extern vsshlids.h と IDM_VS_CTXT_RESULTSLIST を忘れない
3ProvideMenuResource 属性を追加これがないと .vsct が読み込まれない
4InitializeAsync でコマンド登録OleMenuCommandService から OleMenuCommand を作る
5選択テキスト取得ロジックを実装SVsTextManager → IVsTextView.GetSelectedText
6BeforeQueryStatus で可視性制御(必要に応じて)出力ウィンドウ以外では Visible=false に
7必要なビジネスロジックを実装外部ツール連携、URL オープンなど自由に拡張

まとめ:出力ウィンドウは「Results List」として扱うのが正解

最後に、本記事のポイントを簡潔にまとめます。

  • Visual Studio の「出力」ウィンドウはドキュメントエディタではなく Results List 系のコンテキストに属している
  • そのため、DocumentWindowCommandSet / DocumentContextMenu を親にしてもメニューは表示されない
  • .vsct では、親メニューとして
    • guidSHLMainMenu
    • IDM_VS_CTXT_RESULTSLIST
    を指定するのが正解
  • ProvideMenuResource 属性と InitializeAsync でのコマンド登録を忘れないこと
  • 選択テキストは SVsTextManager / IVsTextView から簡単に取得できる
  • BeforeQueryStatus でアクティブツールウィンドウを判定すれば、「出力」ウィンドウ専用のメニューとして振る舞わせることも可能

一見ささいな違いに見えますが、「出力」ウィンドウがどのコンテキストメニューにぶら下がっているかを正しく押さえておくことで、無駄な試行錯誤を避け、拡張の幅を大きく広げることができます。ビルドログやテスト結果、デバッグ出力など、日々眺めている「出力」ウィンドウを、ちょっとした自動化や外部連携の入口として活用してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次