次の方法で共有



December 2009

Volume 24 Number 12

Team System - Visual Studio チーム エクスプローラーの機能拡張を構築する

By Brian A. Randell, Marcel de de | December 2009

サンプル コードのダウンロード

Team Foundation Server (TFS) の操作に使用する主なユーザー インターフェイスは、チーム エクスプローラー クライアントです。チーム エクスプローラーを使用すると、複数の TFS サーバーにアクセスしたり、サーバーから情報を取得したりすることができます。既定で、チーム エクスプローラーでは、作業項目、SharePoint ドキュメント ライブラリ、レポート、ビルド、ソース管理などにアクセスできます。

Marcel の開発チームでは、多数の独立した Visual Studio ソリューションを使用しており、各ソリューションは 5 ~ 10 個のプロジェクトで構成されています。また、各ソリューションでは、1 つの場所で配置、メンテナンス、設計を行っていますが、ソリューション間に依存関係が存在する場合もあります。開発チームでは、TFS のバージョン管理レポジトリ内にある特別なフォルダーを使用して、このような依存関係を管理しています。各ソリューションでは、出力を既知の場所に書き込むので、開発チームは、ビルドを作成するたびに出力を確認しています。別のソリューションの出力に依存するソリューションでは、TFS から取得したときにアセンブリを参照できます。

困ったことに、Marcel の開発チームは問題に直面しました。Visual Studio では、現在のソリューションを基準とした相対パスを使用してファイル参照の情報を格納していますが、開発者は、独自のディレクトリ構造とワークスペース マッピングを使用できる柔軟性を必要としていました。ただし、Visual Studio では、参照ファイルを検出できないことが原因で、ソリューションをビルドできないので、開発者はフラストレーションを感じていました。

この問題を解決する 1 つの方法は、subst.exe コマンドを使用して、バイナリ参照を含む場所を割り当てられたドライブとしてマップすることです。割り当てられたドライブ上にあるアセンブリを参照することで、Visual Studio では、整合性の取れた場所にあるファイルを検出できます。開発者は好きな場所にファイルを格納し、subst.exe を使用して、その場所を標準のマッピングに割り当てられます。ファイルは別のドライブにあるので、Visual Studio では、相対パスではなく完全パスを使用してファイル情報を格納します。また、マッピングを変更するだけで、さまざまなバージョンのテストを実施できるというメリットもあります。

この技法でも問題を解決できますが、開発者がバージョン管理の場所と割り当てられたドライブ間のマッピングを定義できるチーム エクスプローラーの機能拡張を構築することをお勧めします。Marcel の開発チームは、この機能を Subst Explorer という名前のチーム エクスプローラーの機能拡張に実装しました。Subst Explorer の拡張メニューを図 1 に示します。

Tチーム エクスプローラー ウィンドウに読み込まれた Subst Explorer

図 1 チーム エクスプローラー ウィンドウに読み込まれた Subst Explorer

はじめに

独自のチーム エクスプローラー プラグインを構築するには、Visual Studio 2008 Standard Edition 以上、チーム エクスプローラー、および Visual Studio 2008 SDK が必要です。Visual Studio 2008 SDK は、「Visual Studio 拡張性デベロッパー センター」(msdn.microsoft.com/vsx、英語) からダウンロードしてご利用いただけます。

チーム エクスプローラーのプラグインを作成する前に、VSPackage を作成する必要があります。このパッケージでは、Microsoft.VisualStudio.TeamFoundation.Client.dll で定義されている Microsoft.TeamFoundation.Common.ITeamExplorerPlugin インターフェイスを実装するクラスを提供します。

Visual Studio の専門用語を使うと、プラグインは階層に組み込まれます。チーム エクスプローラーの階層では、作業項目やドキュメントなどの階層を読み込むときに TFS にリモート クエリを実行する必要があるため、階層の非同期読み込みがサポートされています。これは、チーム エクスプローラーの階層が、Visual Studio に実装されている他の階層と異なる点です。このようなリモート クエリを非同期に実行できなければ、Visual Studio の処理がブロックされ、結果としてユーザー エクスペリエンスが低下することになります。VSPackage に実装された ITeamExplorerPlugin インターフェイスにより、チーム エクスプローラーで各ノードのコンテンツを非同期に読み込むメカニズムが提供されます。

VSPackage を作成するには、[ファイル] メニューの [新規作成] をポイントし、[プロジェクト] をクリックします。[新しいプロジェクト] ダイアログ ボックスの [その他のプロジェクトの種類] を展開し、[機能拡張] をクリックします。[テンプレート] ペインで [Visual Studio Integration Package] を選択します。

[新しいプロジェクト] ダイアログ ボックスで必要な情報を指定して、[OK] をクリックすると、Visual Studio Integration Package のウィザードが起動します。まず、開発に使用する言語 (C++、C#、または Visual Basic) を選択します (この記事では C# を使用しました)。Visual Basic または C# を選択した場合は、新しい厳密な名前のキー ファイルを生成するオプションを選択するか、既存のファイルを指定する必要があります。次のページでは、会社の情報とパッケージに関する情報 (パッケージ名、アイコンなど) を入力します。このページで入力した情報の大半は、Visual Studio で [ヘルプ] メニューの [バージョン情報] をクリックしたときに表示されます。

その次のページでは、Visual Studio でパッケージを公開する方法を選択します。この記事では、[Menu Command] チェック ボックスのみを選択しました。プラグインでは、このページで選択したオプションに基づいてコンテキスト メニューを処理します。その次のページでは、コマンドの名前と ID を指定します。これらの値は後で変更するので、ここでは既定値を使用します。次のページでは、テスト プロジェクトのサポートを追加できます。この記事では、このオプションについて詳しく説明しませんので、チェック ボックスをオフにして、[Finish] をクリックして、ウィザードを完了します。このウィザードでは、VSPackage の実装に必要な基本クラスとリソースが生成されます。

今度は、チーム エクスプローラーのプラグインを作成するのに使用するチーム エクスプローラーの基本クラスへのアクセスを提供する次の参照を追加する必要があります。

Microsoft.VisualStudio.TeamFoundation.Client.(9.0.0)
Microsoft.VisualStudio.TeamFoundation (9.0.0)
Microsoft.VisualStudio.Shell (2.0.0)

また、Team Foundation は、バージョン 9.0 のアセンブリではなく、バージョン 2.0 のアセンブリに対してビルドされているので、既定で追加されている Microsoft.VisualStudio.Shell.9.0 への参照を削除する必要があります。さらに、プロジェクトでは、コンパイル後に、regpkg.exe ツールを使用してパッケージをレジストリに登録できることを想定していますが、regpkg.exe は Shell.9.0 アセンブリに依存しています。プロジェクトを Visual Studio 2008 でビルドするには、プロジェクトの .proj ファイルを変更する必要があります。プロジェクト ファイルをアンロードし、ファイルの RegisterOutputPackage プロパティの下に次のプロパティを追加する必要があります。

<!-- We are 2005 compatible, and don't rely on RegPkg.exe
of VS2008 which uses Microsoft.VisualStudio.Shell.9.0 --> 
<UseVS2005MPF>true</UseVS2005MPF> 
<!-- Don't try to run as a normal user (RANA), 
create experimental hive in HKEY_LOCAL_MACHINE --> 
<RegisterWithRanu>false</RegisterWithRanu>.

Microsoft.VisualStudio.TeamFoundation.Client アセンブリでは、PluginHostPackage という名前の基本クラスを含む Microsoft.TeamFoundation.Common 名前空間を提供します。これをパッケージの基本クラスとして使用します。この名前空間には、プラグインに必要な ITeamExplorerPlugin インターフェイスを実装する BasicAsyncPlugin という名前の基本クラスも含まれています。生成された Package クラスの既定の実装を削除し、既定の Package クラスではなく、PluginHostPackage クラスを継承するようにする必要があります。

クラスは PluginHostPackage クラスを継承するようになったので、必要な作業は、OnCreateService メソッドをオーバーライドするだけです。このメソッドでは、実際のプラグインの実装を管理する BasicAsyncPlugin 派生クラスの新しいインスタンスを返します。Subst Explorer の HostPackage の実装は図 2 のとおりです。また、チーム エクスプローラーのプラグインは手動で登録する必要があります (この作業については、この記事の後半で説明します)。

図 2 SubstExplorerPackage クラスの実装

...

[ProvideService(typeof(SubstExplorer))]
[PluginRegistration(Catalogs.TeamProject, "Subst explorer", typeof(SubstExplorer))]
public sealed class SubstExplorerPackage: PluginHostPackage, IVsInstalledProduct {
  private static SubstExplorerPackage _instance;
  public static SubstExplorerPackage Instance {
    get { return _instance; }
  }

  public SubstExplorerPackage () : base() {
    _instance = this;
  }
        
  protected override object OnCreateService(
    IServiceContainer container, Type serviceType) {

    if (serviceType == typeof(SubstExplorer)) {
      return new SubstExplorer();
    }
    throw new ArgumentException(serviceType.ToString());
  }
}

図 2 に示すように、チーム エクスプローラーに関連のある属性は 2 つあります。ProvideService 属性は、このパッケージでサービスを提供していることを示し、ServiceType 属性には SubstExplorer という値が設定されています。PluginRegistration 属性は、このパッケージでチーム エクスプローラーのプラグインを提供していることと、追加の登録が必要なことを示しています。この属性は、RegistrationAttribute クラスから派生しており、通常、regpkg.exe で処理されます。

ノードと階層

図 2 のとおり、OnCreateService メソッドは簡単に実装できます。OnCreateService メソッドでは、BasicAsyncPlugin クラスの実装を提供する SubstExplorer クラスの新しいインスタンスを返します。SubstExplorer クラスでは、チーム エクスプローラーの階層の一部を管理します。Visual Studio の階層は、各ノードに一連のプロパティが関連付けられているノードのツリー構造です。Visual Studio で提供される他の階層には、ソリューション エクスプローラー、サーバー エクスプローラー、パフォーマンス エクスプローラーがあります。

SubstExplorer クラスでは、CreateNewTree メソッドと GetNewUIHierarchy メソッドをオーバーライドすることでプラグインの階層を管理します。図 3 に BasicAsyncPlugin クラスから派生した SubstExplorer クラスの実装を示します。

図 3 SubstExplorer クラスの実装

[Guid("97CE787C-DE2D-4b5c-AF6D-79E254D83111")]
public class SubstExplorer : BasicAsyncPlugin {
  public SubstExplorer() : 
    base(MSDNMagazine.TFSPlugins.SubstExplorerHostPackage.Instance) {}

  public override String Name
  { get { return "Subst drive mappings"; } }

  public override int DisplayPriority {
    get { 
      // After team explorer build, but before any installed power tools
      // power tools start at 450
      return 400; 
    }
  }

  public override IntPtr OpenFolderIconHandle
  { get { return IconHandle; }}

  public override IntPtr IconHandle
  { get { return new Bitmap(
    SubstConfigurationFile.GetCommandImages().Images[2]).GetHicon(); } }

  protected override BaseUIHierarchy GetNewUIHierarchy(
    IVsUIHierarchy parentHierarchy, uint itemId) { 

    SubstExplorerUIHierarchy uiHierarchy = 
      new SubstExplorerUIHierarchy(parentHierarchy, itemId, this);
    return uiHierarchy;
  }

  protected override BaseHierarchyNode CreateNewTree(
    BaseUIHierarchy hierarchy) {

    SubstExplorerRoot root = 
      new SubstExplorerRoot(hierarchy.ProjectName + 
      '/' + "SubstExplorerRoot");
    PopulateTree(root);
    // add the tree to the UIHierarchy so it can handle the commands
    if (hierarchy.HierarchyNode == null)
      { hierarchy.AddTreeToHierarchy(root, true); }
    return root;
  }

  public static void PopulateTree(BaseHierarchyNode teNode) {
    string projectName = 
      teNode.CanonicalName.Substring(0, 
      teNode.CanonicalName.IndexOf("/"));
    var substNodes = 
      SubstConfigurationFile.GetMappingsForProject(projectName);
    if (substNodes != null) {
      foreach (var substNode in substNodes) {
        SubstExplorerLeaf leafNode = 
          new SubstExplorerLeaf(substNode.name, substNode.drive, 
          substNode.versionControlPath);
        teNode.AddChild(leafNode);
      }
      // (bug workaround) force refresh of icon that changed 
      // during add, to force icon refresh
      if (teNode.IsExpanded) {
        teNode.Expand(false);
        teNode.Expand(true);
      }
    }
  }
}

SubstExplorer クラスでは、一連の階層ノードの作成を管理します。SubstExplorer パッケージのノードは、プラグインでドライブとして割り当てられる仮想のフォルダーの場所を表します。各ノードには、subst.exe コマンドを使用してドライブを割り当てるのに必要なプロパティが設定されています。パッケージでは、名前、ドライブ文字、および (バージョン管理レポジトリの) 場所を追跡します。

パッケージでは、ツリーを作成する際に 2 つの手順を踏みます。1 つ目の手順では、すべての階層ノードのコマンド ハンドラー クラス (通称、UIHierarchy) を作成します。この手順は、GetNewUIHierarchy メソッドによって開始されます。2 つ目の手順では、CreateNewTree メソッドによって仮想ドライブの割り当てを表すノードのツリーが作成されます。

GetNewUIHierarchy メソッドは UI スレッドから呼び出され、BaseUIHierarchy 基本クラスから派生したクラスのインスタンスを返します。パッケージは、SubstExplorerUIHierarchy クラスに実装されます。SubstExplorerUIHierarchy クラスでは、パッケージでチーム エクスプローラーに追加した任意のノードで実行された Add、Delete、および Edit のコマンドを処理する必要があります。ExecCommand メソッドで、これらのコマンドを処理します。ですが、その前に、Visual Studio で表示するメニューとコマンドを作成する必要があります。

SubstExplorer クラスでは、UI 以外のスレッドから呼び出されて、チーム プロジェクト用に構成された割り当てられたドライブを表すノードのツリーを返す CreateNewTree メソッドをオーバー ライドします。このツリーには、RootNode クラスから派生したルート ノードが必ずあります。各定義では、このルート ノードに子ノードを追加します。リーフ ノードには、ドライブを割り当てるのに必要なプロパティが格納されています。

コマンドとプロパティ

チーム エクスプローラーのプラグインを設定するのに必要な基本的な作業は完了したので、今度は、プラグインに機能を追加します。SubstExplorerRoot クラスは、Microsoft.TeamFoundation.Common アセンブリの RootNode クラスから派生しています。ここでは、Icons プロパティ、PropertiesClassName プロパティ、および ContexMenu プロパティをオーバーライドします。

Icons プロパティは、ノードを表示するときに使用するアイコンを含む ImageList を返します。RootNode クラスのコンストラクターでは、ImageList に格納された適切な画像をポイントするように、ImageIndex を設定する必要があります。

PropertiesClassName プロパティは、ノードを選択したときにプロパティ ウィンドウに表示される名前を表す文字列を返します。このプロパティには、適切だと思われる任意の文字列を設定できます。

ContextMenu プロパティは、表示するコンテキスト メニューを表す CommandID を返します。ルート ノードでは、Add という 1 つのコマンドを表示するコンテキスト メニューが必要です。図 4 に、SubstExplorerRoot クラスの実装を示します。

図 4 SubstExplorerRoot クラスの実装

public class SubstExplorerRoot : RootNode {
  static private readonly CommandID command = 
    new CommandID(GuidList.guidPackageCmdSet, 
    CommandList.mnuAdd);

  public SubstExplorerRoot(string path) : base(path) {
    this.ImageIndex = 2;
    NodePriority  = (int)TeamExplorerNodePriority.Folder;
  }

  public override System.Windows.Forms.ImageList Icons
  { get { return SubstConfigurationFile.GetCommandImages();  } }

  public override string PropertiesClassName {
    //Name of the node to show in the properties window
    get { return "Subst Explorer Root"; }
  }

  public override 
    System.ComponentModel.Design.CommandID ContextMenu
  { get { return command; } }
}

リーフ ノードの SubstExplorerLeaf クラス (図 5 参照) は、BaseHierarchyNode クラスから派生しており、ここでは、ContextMenu プロパティ、PropertiesClassName プロパティ、および PropertiesObject プロパティをオーバーライドする必要があります。また、DoDefaultAction メソッドのカスタム実装も提供する必要があります。Visual Studio では、リーフ ノードをダブルクリックすると、このメソッドが呼び出されます。DoDefaultAction メソッドでは、Subst コマンドを実行するコードを実行します。以前に Subst コマンドを実行している場合は、ドライブの割り当てが削除されます。

図 5 SubstExplorerLeaf クラスの実装

public class SubstExplorerLeaf : BaseHierarchyNode {
  private enum SubstIconId {
    unsubsted = 1,
    substed = 2
  }

  CommandID command = 
    new CommandID(GuidList.guidPackageCmdSet, 
    CommandList.mnuDelete);
  bool IsDriveSubsted { get; set; }

  public string VersionControlPath { get; set; }
  public string SubstDriveLetter { get; set; }

  public SubstExplorerLeaf(string path,  
    string substDriveLetter, string versionControlPath)
    : base(path, path + " (" + substDriveLetter + ":)") {

    this.ImageIndex = (int)SubstIconId.unsubsted;
    this.NodePriority = (int)TeamExplorerNodePriority.Leaf;
          
    this.VersionControlPath = versionControlPath;
    this.SubstDriveLetter = substDriveLetter;
    this.IsDriveSubsted = false;
  }

  public override void DoDefaultAction() {
    if (!IsDriveSubsted) {
      SubstDrive();
    }
    else {
      UnsubstDrive(SubstDriveLetter);
    }
  }

  public override CommandID ContextMenu
  { get { return command;  } }

  public override string PropertiesClassName
  { get { return "Subst Leaf Node"; }}

  public override ICustomTypeDescriptor PropertiesObject {
    get {
      return new SubstExplorerProperties(this);
    }
  }

  private void SubstDrive() {
    if (IsDriveAlreadySubsted(SubstDriveLetter)) {
      UnsubstDrive(SubstDriveLetter);
    }
    string substresponse = 
      SubstHelper.Subst(SubstDriveLetter, GetLocalFolder());
 
    if (string.IsNullOrEmpty(substresponse)) {
      IsDriveSubsted = true;
      this.ImageIndex = (int)SubstIconId.substed;
    }
    else {
      MessageBox.Show(string.Format(
        "Unable to make subst mapping. Message:\n {0}", 
        substresponse));
    }
  }

  private bool IsDriveAlreadySubsted(string driveLetter) {
    bool IsdrivePhysicalyMaped = 
      SubstHelper.SubstedDrives().Where(
      d => d.Contains(driveLetter + ":\\")).Count() != 0;
    bool IsdriveKnownToBeMaped = 
      (from substedNode in _substedNodes
      where substedNode.SubstDriveLetter == driveLetter
      select substedNode).ToArray<SubstExplorerLeaf>().Length > 0;
    return IsdriveKnownToBeMaped || IsdrivePhysicalyMaped;
  }

  public void UnsubstDrive(string substDriveLetter) {
    string substResponse = SubstHelper.DeleteSubst(substDriveLetter);
    IsDriveSubsted = false;
    this.ImageIndex = (int)SubstIconId.unsubsted;
  }

  public string localPath {
    get { return VersionControlPath; }
  }
}

ContextMenu プロパティは、リーフ ノードで表示するコンテキスト メニューを表します。コンテキスト メニューでは、Properties と Delete という 2 つのコマンドを表示します。PropertiesClassName プロパティは、ルート ノードの場合と同じ目的を持っています。PropertiesObject プロパティは、選択したノードのプロパティをプロパティ ウィンドウに表示するのに使用できるオブジェクトを取得するために使用します。リーフ ノードで公開されるプロパティは、Name プロパティ、DriveLetter プロパティ、および VersionControlPath プロパティです。

SubstExplorerProperties クラスのオブジェクトの新しいインスタンスを返します (図 6 参照)。このオブジェクトを使用して、リーフ ノードのプロパティを表示します。SubstExplorerProperties クラスでは、表示するプロパティとその表示方法に関する情報を返す ICustomTypeDescriptor インターフェイスの実装を提供します。BaseHierarchyNode クラスには、URL、ServerName、ProjectName などの項目を表示する既定のプロパティ オブジェクトが用意されていますが、このプロジェクトのリーフ ノードで使用するのには適していませんでした。

図 6 SubstExplorerProperties クラスの実装

public class SubstExplorerProperties 
  : ICustomTypeDescriptor, IVsProvideUserContext {

  private BaseHierarchyNode m_node = null;
  public SubstExplorerProperties(BaseHierarchyNode node)
    { m_node = node; }

  public string GetClassName()
    { return m_node.PropertiesClassName;}

  public string GetComponentName()
    { return m_node.Name; }
  public PropertyDescriptorCollection 
    GetProperties(Attribute[] attributes) { 

    // create for each of our properties the 
    // appropriate PropertyDescriptor
    List<PropertyDescriptor> list = new List<PropertyDescriptor>();
    PropertyDescriptorCollection descriptors = 
      TypeDescriptor.GetProperties(this, attributes, true);

    for (int i = 0; i < descriptors.Count; i++) {
      list.Add(new DesignPropertyDescriptor(descriptors[i]));
    }
    return new PropertyDescriptorCollection(list.ToArray());
  }

  public object GetPropertyOwner(PropertyDescriptor pd) {  
    // return the object implementing the properties
    return this;
  }

  // rest of ICustomTypeDescriptor methods are not 
  // shown since they are returning defaults 
  // actual properties start here
  [Category("Drive mapping")]
  [Description("...")]
  [DisplayName("Version Control Path")]
  public string VersionControlPath
    {  get { return ((SubstExplorerLeaf)m_node).VersionControlPath; } }

  [Category("Drive mapping")]
  [Description("...")]
  [DisplayName("Subst drive letter")]
  public SubstDriveEnum SubstDriveLetter {
    get { return 
      (SubstDriveEnum)Enum.Parse(typeof(SubstDriveEnum),
      ((SubstExplorerLeaf)m_node).SubstDriveLetter); }
  }

  [Category("Drive mapping")]
  [Description("...")]
  [DisplayName("Mapping name")]
  public string MappingName
    {  get { return ((SubstExplorerLeaf)m_node).Name; } }
}

コマンドとメニュー

ルート ノードとリーフ ノードの実装を調査すると、どちらのノードでもコンテキスト メニューを表示する必要があることがわかります。ルート ノードでは、Add コマンドを表示する必要があります。リーフ ノードでは、Delete コマンドと Properties コマンドを表示する必要があります。どちらのノードの実装でも、それぞれに設定されている ContextMenu プロパティの実装として CommandID クラスのインスタンスを返します。CommandID クラスが適切に動作するには、ソリューション内でメニューとコマンドを定義する必要があります。

Visual Studio にメニューとコマンドを追加するには、コマンド テーブルでコマンドを定義する必要があります。コマンド テーブルは、埋め込まれたリソースとしてアセンブリに追加します。また、パッケージを登録するときに、コマンド テーブルとシステム レジストリを登録する必要があります。devenv /setup コマンドを実行すると、Visual Studio によって、登録されている全パッケージからすべてのコマンド リソースを収集し、開発環境内にあるすべてのコマンドの内部表現が構築されます。

Visual Studio 2005 以降では、.vsct という拡張子を使用して、XML ファイルでコマンド テーブルを定義できます。このファイルでは、メニューと、メニューに表示するコマンド グループおよびボタンを定義します。Visual Studio のコマンドは、コマンド グループに属しており、コマンド グループはメニューに配置します。

ルート ノードでは、Add コマンドが必要で、これをメニューのコマンド グループに配置します。また、リーフ ノードでは、Delete コマンドと Properties コマンドが必要なので、この 2 つのコマンドを含むコマンド グループを配置する別のメニューを定義する必要があります (.vsct ファイルのサンプルについては、この記事付属のコード サンプルを参照してください)。

Visual Studio プロジェクトでは、.vsct ファイルに特別な処理を施す必要があります。このファイルをリソースとしてコンパイルし、このリソースをアセンブリに埋め込む必要があります。Visual Studio SDK をインストールすると、コマンド ファイルに対して実行できる特別なビルド操作 (VSCTCompile) を選択できるようになります。このコマンドを実行すると、ファイルがコンパイルされ、リソースがアセンブリに埋め込まれます。

XML ファイルで定義したコマンド テーブルでは、メニューとコマンドの定義にシンボルを使用しています。すべてのメニュー、コマンド、およびボタンは、GuidPackageCmdSet と言う名前の同じコマンド セットに追加します。

<Symbols>
  <!-- This is the package guid. -->
  <GuidSymbol name="GuidPackage" value=
"{9B024C14-2F6F-4e38-AA67-3791524A807E}"/>
  <GuidSymbol name="GuidPackageCmdSet" value=
"{D0C59149-AC1D-4257-A68E-789592381830}"/>
    <IDSymbol name="mnuAdd" value="0x1001" />
    <IDSymbol name="mnuDelete" value="0x1002" />

このシンボルにはメニューが格納されているので、コンテキスト メニューの情報を提供する必要がある箇所では、このシンボルを参照します。このように、SubstExplorerRoot クラスと SubstExplorerLeaf クラスの実装では、CommandID クラスのインスタンスを作成し、GuidPackageCmdSet コマンド セットを 1 つ目の引数として、表示する実際のメニューを 2 つ目の引数として使用します。

CommandID command = new CommandID(
  GuidList.guidPackageCmdSet, 
  CommandList.mnuDelete);

.vsct ファイルには、UIHierarchy クラスで応答する必要がある 3 つのコマンドが含まれています。ExecCommand メソッドは、いずれかのメニュー項目をクリックしたときに呼び出されます。このメソッドでは、渡された nCmdId の値に基づいて実行する操作を選択する必要があります。SubstExplorerUIHierarchy クラスの基本的な実装を図 7 に示します。

図 7 SubstExplorerUIHierarchy クラスの実装

public class SubstExplorerUIHierarchy : BaseUIHierarchy, 
  IVsHierarchyDeleteHandler, IVsHierarchyDeleteHandler2 {

  public SubstExplorerUIHierarchy(IVsUIHierarchy parentHierarchy, 
    uint itemId, BasicAsyncPlugin plugin)
    : base(parentHierarchy, itemId, plugin, 
    MSDNMagazine.TFSPlugins.SubstExplorerHostPackage.Instance) { 
  }
        
  public override int ExecCommand(uint itemId, 
    ref Guid guidCmdGroup, uint nCmdId, 
    uint nCmdExecOpt, IntPtr pvain, IntPtr p) {

    if (guidCmdGroup == GuidList.guidPackageCmdSet) {
      switch (nCmdId) {
        case (uint)CommandList.cmdAdd:
             AddNewDefinition(this.ProjectName);
             return VSConstants.S_OK;
        case (uint)CommandList.cmdDelete:
             RemoveDefinition(itemId);
             return VSConstants.S_OK;
        case (uint)CommandList.cmdEdit:
             EditDefinition(itemId);
             return VSConstants.S_OK;
        default: return VSConstants.E_FAIL;
      }
    }

    return base.ExecCommand(itemId, ref guidCmdGroup, nCmdId, nCmdExecOpt, pvain, p);
  }
  ...
}

Add、Edit、および Delete

今度は、ユーザーが、ルート ノードまたはリーフ ノードで、割り当てを追加、削除、または編集するための手段を提供する必要があります。Add コマンドの呼び出しはルート ノードで処理し、Edit コマンドと Delete コマンドの呼び出しはリーフ ノードで処理するコードを配置しています。新しい割り当ての追加には、ユーザーによる入力が必要で、割り当てのデータは既知の場所に格納する必要があります。この場所としては、ユーザーの移動プロファイルを使用するのが適切です。では、Add コマンドに対応する方法について詳しく見てみましょう。

SubstExplorerUIHierarchy クラスの AddNewDefinition メソッドで Add コマンドに対応します。AddNewDefinition メソッドでは、ユーザーが作成する必要のある割り当てを指定できるダイアログ ボックスを表示します。ドライブの割り当てには、Subst コマンドで使用する名前とドライブ文字が必要です。また、バージョン管理レポジトリのパスをポイントしている必要もあります。この際、ユーザーが複雑なパスを手動で入力するのではなく、バージョン管理レポジトリの場所を選択できるようにすることをお勧めします。これは、TFS のオブジェクト モデル (具体的には、TeamFoundationServerFactory クラスの GetServer メソッド) を使用して実現できます。GetServer メソッドでは、使用するサーバーを表す URL を受け取ります。また、ユーザーがサーバーと同じドメインに属しておらず、サーバーへの接続時に新たに認証を行う必要がある場合は、credentialsProvider を受け取ります。有効な TeamFoundationServer のインスタンスへのアクセスを確立したら、TFS で提供されるさまざまなサービスにアクセスできます。

現在のチーム プロジェクトのフォルダー構造に関する情報を取得するには、VersionControlServer サービスが必要です。Brian が 2007 年 1 月号の MSDN Magazine で執筆した Team System コラム (msdn.microsoft.com/ja-jp/magazine/cc163498.aspx) では、このサービスを使用して、独自のバージョン コントロール フォルダー参照ダイアログ ボックスを作成する方法が紹介されています。ここでは、この記事で紹介されていたダイアログ ボックスを再利用しました (図 8 参照)。このダイアログ ボックスでは、図 9 に示すように、ユーザーがバージョン管理レポジトリで選択したフォルダーを返します。返されたパスは、構成ファイルに格納します。

ユーザーが [OK] をクリックしたら、新しいノードを構成ファイルに追加し、新しい子ノードを階層に追加できます。新しいノードは、HierarchyNode インスタンスで AddChild メソッドを呼び出して追加します。

新しいマッピングの定義を追加する

図 8 新しいマッピングの定義を追加する

バージョン管理レポジトリ内の場所を選択する

図 9 バージョン管理レポジトリ内の場所を選択する

既定のコマンドを実行する

SubstExplorerUIHierarchy クラスでは、プラグインのメニュー オプションで発生する全コマンドに対応する必要があります。このクラスで対応する必要がある、もう 1 つのコマンドは、ユーザーがノードをダブルクリックしたときに発生するコマンドです。このイベントは DoDefaultAction メソッドで処理します。ルート ノードをダブルクリックしたときの既定の操作は、階層のノードを "折りたたむ" か "展開する" のいずれかです。ただし、リーフ ノードでは、カスタム実装を提供する必要があります。

リーフ ノードに設定されているプロパティの値に基づいてドライブを割り当てる必要があります。ドライブを割り当てるには、コマンド ライン操作を発行して、必要なパラメーターを提供します。この処理を行うために、System.Diagnostics 名前空間に呼び出しを行い、subst.exe という名前の新しいプロセスを作成し、このプロセスに必要なパラメーターを提供する SubstHelper クラスを作成しました。渡す必要があるパラメーターは、ドライブ文字とドライブとして割り当てる必要があるローカル フォルダーです。利用できるドライブ文字は提示されます。しかし、バージョン管理レポジトリのパスをローカル フォルダーに割り当てる必要があります。ここでも、TFS オブジェクト モデルを使用して、VersionControlServer オブジェクトへの参照を取得します。このオブジェクトでは、利用できるワークスペースをクエリして、指定したバージョン管理レポジトリのパスに基づいてローカル フォルダーへの割り当てを試行できます。図 10 に、実装の例を示します。

図 10 バージョン管理レポジトリのパスをディスク上の場所に割り当てる

private string GetLocalFolder() {
  VersionControlServer vcs = 
    (VersionControlServer)((
    SubstExplorerUIHierarchy)ParentHierarchy).
    tfs.GetService(typeof(VersionControlServer));
  Workspace[] workspaces = 
    vcs.QueryWorkspaces(null, vcs.AuthenticatedUser, 
    Environment.MachineName);
  foreach (Workspace ws in workspaces) {
    WorkingFolder wf = 
      ws.TryGetWorkingFolderForServerItem(VersionControlPath);
    if (wf != null) {
      // We found a workspace that contains this versioncontrolled item
      // get the local location to map the drive to this location....
      return wf.LocalItem;
    }
  }
  return null;
}

最後のしあげ

ノードのツリーを表示して、ドライブの割り当てに対応するロジックの実装は完了しました。ですが、チーム エクスプローラーのプラグインが目立つようにする必要があります。ノードを削除するときの機能を充実させたり、Visual Studio のスプラッシュ スクリーンにアイコンを追加するなどプロの手法を加えたりすることができます。

削除の機能を追加するには、SubstExplorerUIHierarchy クラスに追加のインターフェイスを実装する必要があります。Visual Studio には、Del キーを押したときに既定のダイアログ ボックスを表示するために実装できる IVsHierarchyDeleteHandler という名前のインターフェイスが用意されています。このプラグインでは、ユーザーが選択したノードを削除することを確認するカスタム ダイアログ ボックスを表示します。このシナリオを実現するためには、キーボードによる削除処理に対応する IVsHierarchyDeleteHandler2 インターフェイスも実装する必要があります。実際に削除を実行する機能は既に実装しているので、必要な作業は、このインターフェイスを実装して、既存の機能を呼び出すだけです。図 11 に、このインターフェイスの実装を示します。

図 11 IVsHierarchyDeleteHandler インターフェイスの実装

#region IVsHierarchyDeleteHandler2 Members

public int ShowMultiSelDeleteOrRemoveMessage(
  uint dwDelItemOp, uint cDelItems, 
  uint[] rgDelItems, out int pfCancelOperation) {

  pfCancelOperation = Convert.ToInt32(true);
  return VSConstants.S_OK;
}

public int ShowSpecificDeleteRemoveMessage(
  uint dwDelItemOps, uint cDelItems, uint[] rgDelItems, 
  out int pfShowStandardMessage, out uint pdwDelItemOp) {

  SubstExplorerLeaf nodeToDelete = 
    NodeFromItemId(rgDelItems[0]) as SubstExplorerLeaf;
  if (AreYouSureToDelete(nodeToDelete.Name)) { 
    pdwDelItemOp = 1; // == DELITEMOP_DeleteFromStorage; 
                      // DELITEMOP_RemoveFromProject==2; 
  }
  else { 
    pdwDelItemOp = 0; // NO delete, user selected NO option }

  pfShowStandardMessage = Convert.ToInt32(false);
  return VSConstants.S_OK;
}

#endregion
#region IVsHierarchyDeleteHandler Members

public int DeleteItem(uint dwDelItemOp, uint itemid) {
  SubstExplorerLeaf nodeToDelete = 
    NodeFromItemId(itemid) as SubstExplorerLeaf;
  if (nodeToDelete != null) {
    // remove from storage
    RemoveDefinitionFromFile(nodeToDelete);
    // remove from UI
    nodeToDelete.Remove();
  }
  return VSConstants.S_OK;
}

public int QueryDeleteItem(uint dwDelItemOp, uint itemid, 
  out int pfCanDelete) {

  pfCanDelete = Convert.ToInt32(NodeFromItemId(itemid) is SubstExplorerLeaf);
  return VSConstants.S_OK;
}
#endregion

このプラグインでは、ShowMultiSelDeleteOrRemoveMessage メソッドで pfCancelOperation に True が設定されているため、複数のノードを選択して一度に削除する処理はサポートしていない点に注意してください。ShowSpecificDeleteRemoveMessage メソッドの実装では、削除する必要があるものの適切な値を返す必要があります。ストレージから削除したことを示す場合は、値 1 を返します。通常、このフラグは、Visual Studio プロジェクト システムで使用されるもので、値 1 が返された場合にのみ適切な結果が生成されます。

スプラッシュ スクリーン統合のサポートを追加することもできます。既定では、Visual Studio を起動するたびに、登録されている製品の一覧を含むスプラッシュ スクリーンが表示されます。これは、SubstExplorerHostPackage 実装クラスの IVsInstalledProduct インターフェイスを実装することで実現できます。このインターフェイスのメソッドでは、スプラッシュ スクリーンで使用するアイコンと [バージョン情報] ダイアログ ボックスで使用するアイコンのそれぞれについて、リソース ID を登録する必要があります。

この実装に必要なことは、適切な正数値をパラメーターに設定し、32x32 ピクセルのアイコンをアセンブリにリソースとして埋め込むだけです。アセンブリにリソースを適切に埋め込むには、XML エディターで resources.resx ファイルを開き、リソース ファイルに次のコード行を追加する必要があります。

<data name="500" 
  type="System.Resources.ResXFileRef, System.Windows.Forms">
  <value>..\Resources\SplashIcon.bmp;System.Drawing.Bitmap, 
    System.Drawing, Version=2.0.0.0, Culture=neutral, 
    PublicKeyToken=b03f5f7f11d50a3a</value> 
</data>

このコード行により、プロジェクトの Resources フォルダーにあるビットマップ リソースがリソースに追加され、リソースは参照 500 で埋め込まれます。IdBmpSplash メソッドでは、pIdBmp に 500 を設定し、戻り値として S_OK を設定できます。スプラッシュ スクリーンにアイコンを表示するには、アセンブリをビルドしてから、コマンド ラインで devenv /setup コマンドを実行する必要があります。このコマンドでは、作成したパッケージから情報を取得して、そのデータをキャッシュします。このキャッシュ データにより、Visual Studio でスプラッシュ スクリーンが表示されるときにパッケージを読み込む必要がなくなります。この処理は、追加したメニュー オプションと同様に、Visual Studio の起動を高速化するという同じ目的のために実行しています。

パッケージの登録

チーム エクスプローラーの機能拡張が完成したら、成果物をパッケージ化して、他の開発者のシステムで実行できます。では、成果物を配布する方法について説明しましょう。

まず、SDK をインストールすると、Visual Studio の動作が変化します。既定では、任意の VSPackage を受け付けます (読み込みます)。ですが、SDK がインストールされていないコンピューターでは動作が異なります。

パッケージが適切に読み込まれるようにするには、パッケージ読み込みキーを埋め込む必要があります。パッケージ読み込みキーは、msdn.microsoft.com/vsx/cc655795 (英語) から入手できます。この処理で一番重要なことは、キーの登録時には、HostPackage クラス (この場合は、SubstExplorerHostPackage クラス) の属性値に指定したのと同じ情報を提供することです。Web サイトで、パッケージ名を入力する際には、ProvideLoadKey 属性に指定した製品名を入力する必要があります。

読み込みキーを取得したら、ProvideLoadKey 属性の最後の引数に指定したリソース ID と併せてリソース ファイルに貼り付けます。サイトから文字列をコピーするときには、改行を削除して、リソース ファイルに貼り付けるときに連続した 1 つの文字列になるようにします。

これで、/NoVsip という追加のデバッグ パラメーターを指定して、プラグインが動作するかどうかをテストできる状態になりました。このパラメーターを指定すると、Visual Studio では、通常の読み込み動作が使用されます。キーが受け付けられない場合は、読み込みに失敗したことを示すメッセージが表示されます。SDK がインストールされている場合、Visual Studio の [ツール] メニューから Package Load Analyzer を起動できます。このツールでは、アセンブリを指定して、問題をデバッグできます。パッケージ読み込みキーにのみ問題がある場合は、Web サイトで、属性値に指定したものと完全に同じパラメーターを指定していることを確認します。

後は、運用環境のコンピューターにパッケージを登録するだけです。Team System のアセンブリでは、異なるバージョンのシェル アセンブリを使用しているので、パッケージの登録に regpkg.exe を使用することができず、レジストリ ファイルを使用して手動で登録する必要があります。レジストリ ファイルでは、適切なレジストリの場所にパッケージを公開する必要があります。必要なレジストリ スクリプトを図 12 に示します。

図 12 パッケージの登録スクリプト

REGEDIT4
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\VisualStudio\9.0\TeamSystemPlugins\Team Explorer Project Plugins\SubstExplorer]
@="97CE787C-DE2D-4b5c-AF6D-79E254D83111"
"Enabled"=dword:00000001
 
[HKEY_LOCAL_MACHINE\Software\Microsoft\VisualStudio\9.0\Services\{97ce787c-de2d-4b5c-af6d-79e254d83111}]
@="{9b024c14-2f6f-4e38-aa67-3791524a807e}"
"Name"="SubstExplorer"
 

[HKEY_LOCAL_MACHINE\Software\Microsoft\VisualStudio\9.0\Packages\{9b024c14-2f6f-4e38-aa67-3791524a807e}]
@="MSDNMagazine.TFSPlugins.SubstExplorerHostPackage, TFSSubstExplorer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=324c86b3b5813447"
"InprocServer32"="C:\\Windows\\system32\\mscoree.dll"
"Class"="MSDNMagazine.TFSPlugins.SubstExplorerHostPackage"
"CodeBase"="c:\\program files\\msdnsamples\\TFSSubstExplorer.dll"
"ID"=dword:00000065
"MinEdition"="Professional"
"ProductVersion"="1.0"
"ProductName"="SubstExplorer"
"CompanyName"="vriesmarcel@hotmail.com"

[HKEY_LOCAL_MACHINE\Software\Microsoft\VisualStudio\9.0\InstalledProducts\SubstExplorerHostPackage]
"Package"="{9b024c14-2f6f-4e38-aa67-3791524a807e}"
"UseInterface"=dword:00000001
 
[HKEY_LOCAL_MACHINE\Software\Microsoft\VisualStudio\9.0\Menus]
"{9b024c14-2f6f-4e38-aa67-3791524a807e}"=", 1000, 1"

登録スクリプトには、多数のエントリが含まれています。1 つ目のエントリでは、チーム エクスプローラーが読み込まれると同時に読み込む必要がある新しいチーム エクスプローラーの機能拡張を登録します。ここでは、ITeamExplorerPlugin インターフェイスの実装を提供するサービスの ID を参照するレジストリ値を指定します。次のエントリでは、サービスを登録します。このエントリには、1 つ目のエントリで参照したサービスの ID とプラグインを提供するパッケージをポイントするレジストリ値が含まれています。

次のエントリでは、パッケージ自体を登録します。このエントリでは、パッケージ ID を新しいキーとして使用し、アセンブリがある場所、COM インフラストラクチャを使用してアセンブリを読み込む方法、パッケージでサポートされている Visual Studio のバージョンに関する情報を提供します。最後から 2 つ目のエントリでは、スプラッシュ スクリーンで使用するインストールされている製品を登録します。ここでは、UseInterface キーにより、Visual Studio では、起動時に表示するアイコンと製品の一覧を提供する際に、InstalledProductRegistration 属性の値ではなく IVsInstalledProduct インターフェイスを呼び出す必要があることを示しています。

最後のエントリでは、ショートカット メニューを登録します。ここでは、パッケージを参照しますが、リソースを埋め込んだアセンブリ内の場所に関する情報も提供します。これは、.vsct ファイルとこのファイルで実行したカスタムのビルド操作によって作成した埋め込まれたリソースです。プラグインは、このスクリプトとビルドしたアセンブリを使用して他のコンピューターに配置できます。必要な作業は、アセンブリをファイル システムに配置し、アセンブリがある場所に合わせてレジストリ スクリプトを調整して、レジストリに統合するだけです。それから、配置先のコンピューターで devenv /setup コマンドを実行すれば、プラグインを使用できるようになります。Visual Studio を起動すると、スプラッシュ スクリーンにアイコンが表示され、チーム エクスプローラーを起動すると、作成したプラグインのルート ノードが表示されます。

Brian A. Randell  は、MCW Technologies LLC のシニア コンサルタントです。マイクロソフト テクノロジに関する講演、講習、および執筆を行っています。Microsoft MVP として、Pluralsight 社の Applied Team System コースの作成にも携わりました。Brian には、彼のブログ (mcwtech.com/blogs/brianr、英語) から連絡できます。

Marcel de Vries* は、オランダの企業 Info Support で IT アーキテクトとして働いています。オランダ国内の大手銀行や保険会が使用するソリューションを作成したり、Team System や Windows Workflow Foundation に関するコースの講師をしています。Marcel は、ヨーロッパで開催される開発者向けのカンファレンスで頻繁に講演を行っています。また、2006 年以来 Team System の MVP を受賞しており、2009 年 1 月からは Microsoft Regional Director として活動しています。*

この記事のレビューに協力してくれた技術スタッフの Dennis Habib と Buck Hodges に心より感謝いたします。