Visual Studio 拡張 VSIX で Tool Window タブのコンテキストメニューにコマンドを追加する方法(VSCT / IDM_VS_CTXT_EZTOOLWINTAB)

Visual Studio の拡張機能(VSIX / VSPackage)で Tool Window を提供していると、「タブ(タイトル部分)を右クリックしたときのコンテキストメニューに、独自のコマンドを追加したい」と思う場面があります。しかし VS SDK の情報は点在していて、どのメニュー ID を親にすればよいか迷いがちです。この記事では、Tool Window の“タブ/枠(タイトル周り)”の右クリックメニューに項目を追加するためのメニュー ID と、.vsct(CommandTable)の実装例、そして“自分のツールウィンドウだけに表示する”実践的な制御までまとめます。

目次

結論:Tool Window のタブ/タイトル右クリックメニューは VSCT で拡張できる

結論から言うと、可能です。Tool Window の「ウィンドウフレーム(枠)/タブ」のコンテキストメニューには既存のメニュー ID が用意されており、そこを Parent(親) に指定して .vsct に Group / Button を追加すれば、右クリックメニューに自分のコマンドを差し込めます。

ここで重要なのは、「Tool Window の右クリック」と一口に言っても、対象が 2 種類あることです。

まず整理:Tool Window の右クリックは“2種類”ある

Tool Window で発生する右クリックは、主に次の 2 つに分かれます。

右クリックする場所対象追加方法の基本よくある勘違い
ツールウィンドウの“中身”WPF/WinForms のコントロール領域XAML / コントロール側の ContextMenu で作るVSCT で追加しようとしてハマる
タブ/タイトル周り(枠)Window Frame / Tab の UIVSCT(.vsct)で既存メニュー ID にぶら下げるどのメニュー ID を使うか分からない

本記事で扱うのは後者、つまり 「ツールウィンドウのタブ/枠(タイトル周り)」の右クリックメニュー を拡張する話です。

使うべき既存メニュー ID:IDM_VS_CTXT_EZTOOLWINTAB / IDM_VS_CTXT_DOCKEDWINDOW

Tool Window のコンテキストメニュー(Window Frame / Tab)として、実務上まず押さえるべき ID は次の 2 つです。

メニュー ID出る場所のイメージ狙いどころ実装上の使い分け
IDM_VS_CTXT_EZTOOLWINTABTool Window のタブ側(ドキュメントタブ風)タブ(タイトル部分)を右クリックまずはここに追加すると“タブ右クリック”で見つかりやすい
IDM_VS_CTXT_DOCKEDWINDOWドッキングされたウィンドウ枠側タイトルバー/枠(ドッキング UI)を右クリックドッキング状態の見た目・操作に合わせてこちらにも追加する

Visual Studio のレイアウトや表示形態(タブ表示か、ドッキング枠か)によって、ユーザーが右クリックする場所が変わります。「タブ右クリックで確実に出したい」なら EZTOOLWINTAB、「枠側でも出したい」なら DOCKEDWINDOW も追加、という設計が分かりやすいです。

実装の全体像:VSCT に Group / Button を追加する

やることはシンプルで、流れは次の通りです。

  1. .vsct に vsshlids.h / stdidcmd.h を Extern で取り込む(既存 ID を参照できるようにする)
  2. Groups を作り、Parent を guidSHLMainMenu + 対象メニュー ID に設定
  3. その Group を親にして Buttons(コマンド) を追加する(必要なら CommandPlacements で複数箇所に配置)
  4. 必要に応じて BeforeQueryStatus 等で「自分の Tool Window のときだけ表示」にする

次から、VSCT の具体例と、C# 側のコマンド実装例をセットで紹介します。

.vsct(CommandTable)の具体例

1) Extern で既存 ID を参照できるようにする

.vsct の先頭付近で、Visual Studio が持っている既存の ID 定義を取り込みます。ここが抜けると、IDM_VS_CTXT_EZTOOLWINTAB などを参照できず、ビルドでエラーになったり、別名で定義し直す羽目になったりします。

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

配置場所は通常、<CommandTable> の直下(またはテンプレートが用意している所)に書きます。

2) Group を作り、Parent を guidSHLMainMenu + メニュー ID にする

右クリックメニューに“差し込み口”を作るイメージです。Group を作成し、その親を該当コンテキストメニューに設定します。

<Groups>
  <Group guid="guidMyPackageCmdSet" id="grpToolWinTabCtx" priority="0x0600">
    <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_EZTOOLWINTAB" />
  </Group>

  <Group guid="guidMyPackageCmdSet" id="grpDockedWinCtx" priority="0x0600">
    <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_DOCKEDWINDOW" />
  </Group>
</Groups>

priority は並び順に影響します。正解は 1 つではありませんが、最初は 0x0600 など中間的な値にして、必要なら調整するのが無難です。

項目役割実務での目安
Groupメニュー内の“区切り(セクション)”自分のコマンド群をまとめ、他のメニューと自然に分離する
Parentどのメニューに表示するかguidSHLMainMenu + IDM_VS_CTXT_EZTOOLWINTAB / IDM_VS_CTXT_DOCKEDWINDOW
priority表示位置の調整まずは固定値で置き、必要に応じて微調整

3) Button(コマンド)を定義し、Group の下に配置する

次にコマンド本体(Button)を作ります。まずは EZTOOLWINTAB 側に素直にぶら下げ、同じコマンドを DOCKEDWINDOW 側にも出したい場合は CommandPlacement を追加するのが分かりやすい構成です。

<Buttons>
  <Button guid="guidMyPackageCmdSet" id="cmdidMyToolWinAction" priority="0x0100" type="Button">
    <Parent guid="guidMyPackageCmdSet" id="grpToolWinTabCtx" />
    <Strings>
      <ButtonText>My Tool Window の処理</ButtonText>
    </Strings>
  </Button>
</Buttons>

<CommandPlacements>
  <CommandPlacement guid="guidMyPackageCmdSet" id="cmdidMyToolWinAction" priority="0x0100">
    <Parent guid="guidMyPackageCmdSet" id="grpDockedWinCtx" />
  </CommandPlacement>
</CommandPlacements>

これで、タブ側の右クリック(EZTOOLWINTAB)にも、ドッキング枠側の右クリック(DOCKEDWINDOW)にも同じコマンドを配置できます。

4) Symbols の例(GUID / ID の定義)

guid と id は、VSCT の Symbols で定義します。テンプレートに近い形でまとめると見通しが良くなります。

<Symbols>
  <GuidSymbol name="guidMyPackageCmdSet" value="{11111111-2222-3333-4444-555555555555}">
    <IDSymbol name="grpToolWinTabCtx" value="0x1020" />
    <IDSymbol name="grpDockedWinCtx" value="0x1021" />
    <IDSymbol name="cmdidMyToolWinAction" value="0x0100" />
  </GuidSymbol>
</Symbols>

もちろん GUID はプロジェクトごとに固有のものを使います。既存の cmdset GUID(テンプレートが作るもの)を流用しても構いません。

C# 側(VSPackage/VSIX)の実装例:OleMenuCommand で登録する

VSCT でメニューに項目を置いても、クリック時の処理は C#(または他言語)側で実装します。ここでは VSIX の典型構成(AsyncPackage + OleMenuCommand)を前提にした、最小限で実務的な例を示します。

Package 側の前提(ProvideMenuResource)

VSCT を使うために、Package クラスに ProvideMenuResource が付いていることを確認します。テンプレートで作っていれば基本的に入っています。

[ProvideMenuResource("Menus.ctmenu", 1)]
public sealed class MyPackage : AsyncPackage
{
    protected override async Task InitializeAsync(
        CancellationToken cancellationToken,
        IProgress<ServiceProgressData> progress)
    {
        await MyToolWinCommand.InitializeAsync(this);
    }
}

※上のコード内に generics 記法がある場合、WordPress の貼り付けで崩れることがあります。必要ならコードブロック内では型を単純化しても問題ありません(本記事の主題はメニュー ID と VSCT です)。

コマンド登録(InitializeAsync)と実行処理

コマンドを OleMenuCommandService に登録します。右クリックメニューに出すだけなら、ここはよくあるテンプレ構成で十分です。

using System;
using System.ComponentModel.Design;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;

internal sealed class MyToolWinCommand
{
    public const int CommandId = 0x0100;
    public static readonly Guid CommandSet = new Guid("11111111-2222-3333-4444-555555555555");

    // 自分の Tool Window の GUID(ToolWindowPane の Guid 属性と揃える)
    private static readonly Guid MyToolWindowGuid = new Guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE");

    private readonly AsyncPackage _package;
    private readonly IVsUIShell _uiShell;

    private MyToolWinCommand(AsyncPackage package, OleMenuCommandService commandService, IVsUIShell uiShell)
    {
        _package = package;
        _uiShell = uiShell;

        var menuCommandId = new CommandID(CommandSet, CommandId);
        var menuItem = new OleMenuCommand(Execute, menuCommandId);

        // 重要:他のツールウィンドウにも出てしまうのを防ぐ
        menuItem.BeforeQueryStatus += BeforeQueryStatus;

        commandService.AddCommand(menuItem);
    }

    public static async Task InitializeAsync(AsyncPackage package)
    {
        await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);

        var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
        var uiShell = await package.GetServiceAsync(typeof(SVsUIShell)) as IVsUIShell;

        if (commandService == null || uiShell == null)
            return;

        _ = new MyToolWinCommand(package, commandService, uiShell);
    }

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

        var cmd = (OleMenuCommand)sender;

        bool isMine = IsMyToolWindowActive();
        cmd.Visible = isMine;
        cmd.Enabled = isMine;
    }

    private bool IsMyToolWindowActive()
    {
        ThreadHelper.ThrowIfNotOnUIThread();

        if (_uiShell.GetCurrentWindowFrame(out IVsWindowFrame frame) != VSConstants.S_OK || frame == null)
            return false;

        // アクティブなフレームが自分のツールウィンドウか判定
        if (frame.GetGuidProperty((int)__VSFPROPID.VSFPROPID_GuidPersistenceSlot, out Guid slotGuid) == VSConstants.S_OK)
        {
            return slotGuid == MyToolWindowGuid;
        }

        return false;
    }

    private void Execute(object sender, EventArgs e)
    {
        ThreadHelper.ThrowIfNotOnUIThread();
        VsShellUtilities.ShowMessageBox(
            _package,
            "Tool Window タブ/枠のコンテキストメニューから実行されました。",
            "MyToolWinCommand",
            OLEMSGICON.OLEMSGICON_INFO,
            OLEMSGBUTTON.OLEMSGBUTTON_OK,
            OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
    }
}

この例のポイントは BeforeQueryStatus です。Tool Window のタブ/枠のコンテキストメニューは Visual Studio 全体の UI なので、VSCT だけで追加すると 他のツールウィンドウ(ソリューションエクスプローラー等)の右クリックにも出てしまう可能性があります。

そこで、アクティブな Window Frame が自分の Tool Window かどうかを判定して、該当時だけ Visible/Enabled を true にしています。拡張機能としての “行儀の良さ” に直結するので、実務では強くおすすめします。

VSCT で追加できるのは“枠(フレーム)のメニュー”。中身は別物

繰り返しになりますが、ここが混同ポイントです。

  • タブ/タイトル(枠)の右クリック:Visual Studio が持つフレーム UI のメニュー → VSCT で拡張
  • 中身(コントロール領域)の右クリック:あなたが作った WPF/WinForms の UI → XAML / コントロール側で ContextMenu

例えば WPF の中身に独自の右クリックメニューを出したいなら、VSCT ではなく次のように書くのが基本です。

<Grid>
  <Grid.ContextMenu>
    <ContextMenu>
      <MenuItem Header="中身の右クリックメニュー" />
    </ContextMenu>
  </Grid.ContextMenu>
</Grid>

この「中身の ContextMenu」と、「タブ/枠の ContextMenu」を両方整えると、ユーザーがどこを右クリックしても期待した操作ができる UI になります。逆に、片方だけを VSCT で何とかしようとすると遠回りになりがちです。

実装チェックリスト:表示されない/出る場所が違う時の確認点

「ビルドは通るのにメニューに出ない」「思った場所に出ない」というときは、次の観点で切り分けると解決が速いです。

症状ありがちな原因確認・対処
右クリックしても項目が一切出ないParent のメニュー ID が違う/狙いが違うIDM_VS_CTXT_EZTOOLWINTAB(タブ)と IDM_VS_CTXT_DOCKEDWINDOW(枠)を両方試し、右クリック位置も変えて確認する
VSCT のビルドで ID が見つからないExtern が不足<Extern href=”vsshlids.h” /> と <Extern href=”stdidcmd.h” /> を追加する
表示されるが、他のツールウィンドウにも出るVSCT は“メニューに置く”だけで、表示条件がないBeforeQueryStatus で自分の Tool Window のときだけ Visible にする(本文の C# 例)
表示されるが、クリックしても何も起きないコマンド登録(OleMenuCommandService)ができていないInitializeAsync が呼ばれているか、CommandId/Guid が VSCT の定義と一致しているか確認
ある状態では出るが、別レイアウトだと出ないタブ表示とドッキング枠でメニューが分かれるEZTOOLWINTAB と DOCKEDWINDOW の 両方に配置(CommandPlacements を使う)
更新したのに反映されないVisual Studio のキャッシュや Experimental Instance の状態Experimental Instance の再起動、必要なら拡張機能の再インストールで確認

“自分のツールウィンドウだけ”に自然に出すための設計ヒント

Tool Window のタブ/枠のコンテキストメニューは、ユーザーにとって「その場のメニュー」です。そこに無関係なコマンドが並ぶと、拡張機能全体の印象が悪くなります。そこで、実務では次の設計にすると事故が減ります。

  • まずは VSCT で追加し、期待した右クリック場所に出ることを確認する
  • 次に BeforeQueryStatus で Visible/Enabled を制御し、他のウィンドウに出ないようにする
  • コマンドが複数あるなら Group でまとめる(区切りがつき、見た目が整う)
  • タブ側と枠側で操作感が変わる環境を想定し、必要なら両方に配置する

また、表示制御の判定は「現在のアクティブフレームが自分か」で十分なケースが多いです。より厳密に「右クリックされたフレームはどれか」を追いたい場合は別のサービスを使った判定も考えられますが、まずはシンプルに組むほうが保守しやすく、ユーザー体験も安定します。

まとめ:探していた“ぶら下げ先”はこの2つ

  • Tool Window のタブ/枠(タイトル周り)の右クリックメニューには、既存メニュー ID がある
  • タブ側は IDM_VS_CTXT_EZTOOLWINTAB、ドッキング枠側は IDM_VS_CTXT_DOCKEDWINDOW
  • .vsct では Extern(vsshlids.h / stdidcmd.h) → Group(Parent をメニュー ID) → Button の順で追加する
  • “他のツールウィンドウにも出る”問題は、BeforeQueryStatus で自分の Tool Window のときだけ表示にすると解決しやすい
  • Tool Window の“中身”の右クリックは VSCT ではなく WPF/WinForms 側の ContextMenu が基本

この記事を書いた人

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

コメント

コメントする

目次