This blog post was written by Hayder Casey, a developer on the work item tracking team.

The work item tracking context menus in Visual Studio are extensible. You can add your own commands to them and interact with the work item tracking editors and the hierarchy of queries and query folders in Team Explorer.

Visual Studio provides two mechanisms to achieve this: Visual Studio Add-in and Visual Studio Package. If you are unfamiliar with Add-ins/Packages I recommend reading the linked articles and deciding which approach you want to use. Packages are the preferred approach if you want deep integration with Visual Studio and TeamFoundation.

In this post I will walk you through extending work item tracking context menus via an add-in or a package for Visual Studio 2010 (the process is essentially the same for older releases). Both sample projects are attached. Both examples will extend two context menus, the query editor and query node in Team Explorer, as shown in the screenshots:

clip_image002[7]

Above: a row in the query editor with a “Clear” option in the context menu.

clip_image004[7]

Above: the query node in Team Explorer with a “Copy Query Expression to Clipboard” option in the context menu.

Attached to the blog post is a zip containing the sample code for both examples.

Visual Studio Add-in:

Start by creating a Visual Studio Add-in: File> New Project, and select ‘Visual Studio Add-in’.

clip_image006[7]

Hit OK. This will bring up the add-in wizard. After you hit Finish and complete the wizard, a project is created for you. There is one class produced called Connect (connect.cs file). This is the main class (point of entry) for an add-in. The Connect class has an OnConnection method (one of the IDTExtensibility2 interface’s members). This is called when the add-in is initialized.

We are interested in extending the command for the work item tracking context menu. First, we need to find the name of the menu we are interested in, add a command to it, then add a handler for executing/querying status to support this command. This process is the same for all Visual Studio menus.

Here are a couple of helper methods for adding commands to the context menu that you can add to the connect class.

/// <summary>
/// Adds a command to VS context menu
/// </summary>
/// <param name="menuName">The name of the menu</param>
/// <param name="commandName">Reference name of the command</param>
/// <param name="commandText">Display name of the command</param>
/// <param name="iconId">MSO icon id of the command</param>
/// <param name="position">position of command 1 = first item in the menu</param>
private void AddCommandToContextMenu(string menuName, string commandName, string commandText, int iconId, int position)
{
CommandBar contextMenu = ((CommandBars)_applicationObject.CommandBars)[menuName];
AddCommand(contextMenu, commandName, commandText, iconId, position);
}

private void AddCommand(CommandBar parent, string commandName, string commandText, int iconId, int position)
{
Commands2 commands = (Commands2)_applicationObject.Commands;
//create the command
Command newCommand = commands.AddNamedCommand2(_addInInstance, commandName, commandText, commandText, true, iconId);
// add it to parent menu
newCommand.AddControl(parent, position);
}

 

 

 

In OnConnection method, we just make a call to AddCommandToContextMenu to add this command:

 

 

public void OnConnection(object application, ext_ConnectMode connectMode, object addInInst, ref Array custom)
{
_applicationObject = (DTE2)application;
_addInInstance = (AddIn)addInInst;

if(connectMode == ext_ConnectMode.ext_cm_UISetup)
{
AddCommandToContextMenu(
WorkItemTrackingMenus.QueryBuilderContextMenu, // context menu Name
"ClearQuery", // menu reference name
"Clear", // display name
47, // command icon
1) // command placement, 1= first item on top
}
}

The command icon ID is the ID for the MSO icon. There are about 8000 of those icons. This blog post has the first 3000 icons listed with their IDs: http://www.kebabshopblues.co.uk/2007/01/04/visual-studio-2005-tools-for-office-commandbarbutton-faceid-property/.

Here is a class that lists the menu names for Work Item Tracking (note: I have not tested this against a localized version of VS):

public static class WorkItemTrackingMenus
{
//context menus
public const string QueryBuilderContextMenu = "Query Builder";
public const string ResultsListContextMenu = "Results List";
public const string WorkItemEditorContextMenu = "Work Item";
public const string ResultListColumnHeaderContextMenu = "Results List Column Sort";
public const string WorkItemTENodeContextMenu = "Work Items";
public const string QueryFolderTENodeContextMenu = "Query Folder";
public const string QueryTENodeContextMenu = "Query";

//toolbar
public const string WorkItemTrackingToolbar = "Work Item Tracking";

}

 

The connect class needs to implement IDTCommandTarget (http://msdn.microsoft.com/en-us/library/envdte.idtcommandtarget.aspx ). It should already implement that interface when you created the project. Here is the interface definition for your reference:

public interface IDTCommandTarget
{
void Exec(string CmdName, vsCommandExecOption ExecuteOption, ref object VariantIn, ref object VariantOut, ref bool Handled);
void QueryStatus(string CmdName, vsCommandStatusTextWanted NeededText, ref vsCommandStatus StatusOption, ref object CommandText);
}

Basically, the Exec method is used to execute commands added by the addin, and QueryStatus is used to determine the status of those commands dynamically. Status of a command can be enabled, disabled, hidden. You can do more with the status command, such as change the text of the command dynamically but I won’t get into that here.

Here is the Exec implementation to handle this command:

public void Exec(string commandName, vsCommandExecOption executeOption, ref object varIn, ref object varOut, ref bool handled)
{
handled = false;
if (executeOption != vsCommandExecOption.vsCommandExecOptionDoDefault)
{
return;
}

IWorkItemTrackingDocument document = GetActiveDocument();
IQueryDocument queryDoc = document as IQueryDocument;

switch (commandName)
{
case "MyAddin1.Connect.ClearQuery":
//we only perform the action when the active document is a query
if (queryDoc != null)
{
queryDoc.QueryText = "";
}
handled = true;
break;
}
}

The name is preceded by the full class name; in this case it’s “MyAddins1.Connect”.

You will need to add some assembly references to the project so that you can use work item tracking extensibility:

  • Microsoft.TeamFoundation.Client.dll
  • Microsoft.TeamFoundation.WorkItemTracking.Client.dll
  • Microsoft.TeamFoundation.WorkItemTracking.Controls.dll
  • Microsoft.VisualStudio.TeamFoundation.Client.dll
  • Microsoft.VisualStudio.TeamFoundation.WorkItemTracking.dll
  • Microsoft.VisualStudio.OLE.Interop.dll
  • Microsoft.VisualStudio.TeamFoundation.dll

GetActiveDocument() is a helper method I wrote that returns the active work item tracking document. There are three types of work item tracking documents: Workitem Editor, Results Editor, and Query Editor. Here is the implementation for GetActiveDocument:

public IWorkItemTrackingDocument GetActiveDocument()
{

if (_applicationObject.ActiveDocument != null)
{
string activeDocumentMoniker = _applicationObject.ActiveDocument.FullName;
IWorkItemTrackingDocument doc = DocService.FindDocument(activeDocumentMoniker, _lockToken);
if (doc != null)
{
doc.Release(_lockToken);
}

return doc;
}

return null;

}

public DocumentService DocService
{
get
{
if (_documentService == null)
{
_documentService = DTEObject.DTE.GetObject("Microsoft.VisualStudio.TeamFoundation.WorkItemTracking.DocumentService")
as DocumentService;
}
return _documentService;
}
}

private object _lockToken = new object(); // token used for locking the document
private DocumentService _documentService;

When retrieving a Document from the DocumentService, you will need to pass a lock. DocumentService will lock it. That document will stay in cache until all locks are released. If your add-in needs to work with the document after it returned execution to Visual Studio, you should not release the document until you are done with it. Otherwise Visual Studio might dispose the document (say when the user closes the editor).

There might be multiple context menus with the same name, so when using the name only to retrieve the menu you will get the first one. I believe “Results List” is the only one in work item tracking that shares the same name with another context menu. To pick the right ResultsList I added a get helper method to retrieve a unique ContextMenu, by providing a unique command on the menu.

private void AddCommandToUniqueContextMenu(string menuName, string uniqueCommandOnMenu, string commandName, string commandText, int iconId, int position)
{
CommandBar contextMenu = GetCommandBar(menuName, uniqueCommandOnMenu);
AddCommand(contextMenu, commandName, commandText, iconId, position);
}

private CommandBar GetCommandBar(string menuName, string commandOnMenu)
{
List<CommandBar> bars = new List<CommandBar>();
foreach (CommandBar menu in ((CommandBars)_applicationObject.CommandBars))
{
if (menu.Name == menuName)
{
try
{
if (menu.Controls[commandOnMenu] != null)
return menu;
}
catch
{
// this is not the corrent menu, even thou the name matches
}
}
}
throw new Exception("Failed to get command bar: " + menuName);
}

This has one extra parameter, ‘uniqueCommandOnMenu’, that you can pass a name of a command that you know on this menu. This will basically keep looking for that menu until it finds one that matches the name AND contains the unique item.

Here is how you use this method from the Connect functions:

 

 

 

public const string UniqueItemOnResultsList = "Print Selection As List...";

AddCommandToUniqueContextMenu(WorkItemTrackingMenus.ResultsListContextMenu,
UniqueItemOnResultsList,"MyCommandRefName", "My Command", 283, 1);

Adding context menus to nodes in the Team Explorer

The process is the same, but we will need to find the node under the context menu so we can execute context sensitive operations on it.

To add a “Copy Query Expression To Clipboard” command to the context menu of a query node in the Team Explorer:

First, add the command to the context menu. In OnConnection method we need to make this call:

AddCommandToContextMenu(WorkItemTrackingMenus.QueryTENodeContextMenu, "CopyQueryWiql", "Copy Query Expression To Clipboard", 19, 1);

And we just need to handle it in the Exec/Status call:

public void Exec(string commandName, vsCommandExecOption executeOption, ref object varIn, ref object varOut, ref bool handled)
{
handled = false;
if (executeOption != vsCommandExecOption.vsCommandExecOptionDoDefault)
{
return;
}

IWorkItemTrackingDocument document = GetActiveDocument();
IQueryDocument queryDoc = document as IQueryDocument;

switch (commandName)
{
case "MyAddin1.Connect.CopyQueryWiql":

string[] tokens = GetCurrentSelectedNodeInTE();

// now let’s get server/project in TE
TfsTeamProjectCollection tpc = TfsTeamProjectCollectionFactory.GetTeamProjectCollection(new Uri(TeamExplorer.GetProjectContext().DomainUri));
WorkItemStore store = tpc.GetService<WorkItemStore>();
Microsoft.TeamFoundation.WorkItemTracking.Client.Project proj = store.Projects[tokens[1]];

//walk the hierarchy till we find the query definition of the selected item
QueryItem qItem = proj.QueryHierarchy;

// start from second token, token[0] = server name, token[1]= project name
int currentTokenIndex = 2;
while (currentTokenIndex < tokens.Length)
{
qItem = (qItem as QueryFolder)[tokens[currentTokenIndex]];
currentTokenIndex++;
}

// we have the query defintion, just copy the expression to the clipboard.
QueryDefinition def = qItem as QueryDefinition;
Clipboard.SetText(def.QueryText);

handled = true;
break;
}
}

Here is the helper method to get TeamExplorer, and the currently selected node in the Team Explorer:

public IVsTeamExplorer TeamExplorer
{
get
{
// get Team Explorer service from VS Add-in
OleInterop.IServiceProvider serviceProvider = (OleInterop.IServiceProvider)_applicationObject;
Guid guid = new Guid(typeof(IVsTeamExplorer).GUID.ToString());
IntPtr ptr;
int result = serviceProvider.QueryService(ref guid, ref guid, out ptr);
if (result != 0)
throw new ApplicationException("Failed to get Team Explorer service");
IVsTeamExplorer TE = (IVsTeamExplorer)Marshal.GetObjectForIUnknown(ptr);

return TE;
}

}

/// <summary>
/// return an array of path token for the current selected node in TE
/// in the form of: {servername, project name, [intermediate nodes,]*, leaf node}
/// </summary>
private string[] GetCurrentSelectedNodeInTE()
{
// Get the TE hierarchy and the selected itemid
IntPtr hier;
uint itemid;
IVsMultiItemSelect dummy;
TeamExplorer.TeamExplorerWindow.GetCurrentSelection(out hier, out itemid, out dummy);

IVsHierarchy hierarchy = (IVsHierarchy)Marshal.GetObjectForIUnknown(hier);
Marshal.Release(hier);

// now that we have the id and hierarchy, we can retrieve lots of properties about the node
// in this case, we get the canonical name which is the in the form of (server/project/[query folders]*/query)

string canonicalName;
hierarchy.GetCanonicalName(itemid, out canonicalName);

string[] tokens = canonicalName.Split('/');

return tokens;
}

Visual Studio Package:

You will notice a lot of similarities between the package and add-in approach. First we need to create a Visual Studio Package project. You will need to install the Visual Studio SDK to see this type of project in the New Project Dialog.

clip_image008[7]

This will create a base project for you package. Several files are added, including WitContextMenuPackage.cs (or <YourPPackageName>Package.cs). This is the main class entry point for a VS package. You will notice that it derives from Microsoft.VisualStudio.Shell.Package (http://msdn.microsoft.com/en-us/library/microsoft.visualstudio.shell.package.aspx)

You will need to add some team foundation assembly references that are needed for extensibility:

Microsoft.TeamFoundation.Client.dll
Microsoft.TeamFoundation.WorkItemTracking.Client.dll
Microsoft.TeamFoundation.WorkItemTracking.Controls.dll
Microsoft.VisualStudio.TeamFoundation.WorkItemTracking.dll
Microsoft.VisualStudio.TeamFoundation.dll
Microsoft.VisualStudio.TeamFoundation.Client.dll

Now add the command to some menus. The command/menus are specified in the *.vsct file (http://msdn.microsoft.com/en-us/library/bb166366.aspx) . You will need to add a Guids.h file that specifies the work item tracking ids. We will need those IDs to place our command under work item tracking context menus. (note that these IDs might change in future releases of Visual Studio):

Content of Guids.h:

#define WorkItemTrackingGuid    { 0x2DC8D6BB, 0x916C, 0x4B80, { 0x9C, 0x52, 0xFD, 0x8F, 0xC3, 0x71, 0xAC, 0xC2 } }

// work item tracking toolbar
#define TBWorkItemTracking 0x100

// TeamExplorer WorkItems node Context Menu
#define TEWorkItems 0x201

// TeamExplorer Query Folder node Context Menu
#define TEQueryGroup 0x202

// TeamExplorer Query node Context Menu
#define TEQuery 0x203

//Query Builder Context Menu
#define QueryBuilder 0x204

//Result List Context Menu
#define ResultList 0x205

// Work Item Editor Context Menu
#define WorkItem 0x206

// Sort Result Header Context Menu
#define ResultListSort 0x209

//Team Foundation Main Menu, use guidSHLMainMenu guid with this ID
//#define IDM_MENU_TEAM_FOUNDATION_CLIENT 0x700

Every menu, group or command in VS is reference by a context Guid/id pair. Work item tracking context menus all use WorkItemTrackingGuid, and each menu has a unique id as specified in the guids.h above.

We will be adding the same commands as the add-in example above: ‘Clear Query’ and ‘Copy Query Expression’. Here is what the vsct file looks like:

<?xml version="1.0" encoding="utf-8"?>
<CommandTable xmlns="http://schemas.microsoft.com/VisualStudio/2005-10-18/CommandTable" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<Extern href="stdidcmd.h"/>
<Extern href="vsshlids.h"/>
<Extern href="msobtnid.h"/>
<Extern href="guids.h"/>

<Commands package="WitContextMenuPkg">
<Groups>
<Group guid="guidWitContextMenuCmdSet" id="GueryBuilderGroup" priority="0x001">
<Parent guid="WorkItemTrackingGuid" id="QueryBuilder"/>
</Group>
<Group guid="guidWitContextMenuCmdSet" id="GueryNodeGroup" priority="0x200">
<Parent guid="WorkItemTrackingGuid" id="TEQuery"/>
</Group>
</Groups>

<Buttons>

<!-- Adding a clear command to query builder context menu-->
<Button guid="guidWitContextMenuCmdSet" id="cmdidClearQuery" priority="0x0100" type="Button">
<Parent guid="guidWitContextMenuCmdSet" id="GueryBuilderGroup" />
<Icon guid="guidImages" id="bmpPicX" />
<CommandFlag>DynamicVisibility</CommandFlag>
<CommandFlag>DefaultInvisible</CommandFlag>
<CommandFlag>DefaultDisabled</CommandFlag>
<Strings>
<CommandName>cmdidClearQuery</CommandName>
<ButtonText>Clear</ButtonText>
</Strings>
</Button>

<!-- Adding copy query expression item to query node context menu-->
<Button guid="guidWitContextMenuCmdSet" id="cmdidCopyQueryExpression" priority="0x0100" type="Button">
<Parent guid="guidWitContextMenuCmdSet" id="GueryNodeGroup" />
<Icon guid="guidImages" id="bmpPicArrows" />
<CommandFlag>DynamicVisibility</CommandFlag>
<CommandFlag>DefaultInvisible</CommandFlag>
<CommandFlag>DefaultDisabled</CommandFlag>
<Strings>
<CommandName>cmdidCopyQueryExpression</CommandName>
<ButtonText>Copy Query Expression</ButtonText>
</Strings>
</Button>

</Buttons>
<Bitmaps>
<Bitmap guid="guidImages" href="Resources\Images_32bit.bmp" usedList="bmpPic1, bmpPic2, bmpPicSearch, bmpPicX, bmpPicArrows"/>
</Bitmaps>
</Commands>
<Symbols>
<!-- This is the package guid. -->
<GuidSymbol name="guidWitContextMenuPkg" value="{caecbd03-bbe1-4118-815e-371e826ed06c}" />

<!-- This is the guid used to group the menu commands together -->
<GuidSymbol name="guidWitContextMenuCmdSet" value="{b7351957-4311-442a-b637-e3bbe3d0ddfd}">

<IDSymbol name="GueryBuilderGroup" value="0x1030" />
<IDSymbol name="GueryNodeGroup" value="0x1040" />

<IDSymbol name="cmdidClearQuery" value="0x0200" />
<IDSymbol name="cmdidCopyQueryExpression" value="0x0300" />

</GuidSymbol>

<GuidSymbol name="guidImages" value="{851f7727-1e2b-4642-b481-066abe7957ed}" >
<IDSymbol name="bmpPic1" value="1" />
<IDSymbol name="bmpPic2" value="2" />
<IDSymbol name="bmpPicSearch" value="3" />
<IDSymbol name="bmpPicX" value="4" />
<IDSymbol name="bmpPicArrows" value="5" />
</GuidSymbol>
</Symbols>

</CommandTable>

The above will add the two commands to the context menus. Now we will need to modify our package code so that it can handle the execution of these commands.

Modify PkgCmdId.cs to include the new command IDs we are adding:

static class PkgCmdIDList
{
public const uint cmdidClearQuery = 0x200;
public const uint cmdidCopyQueryExpression = 0x300;
};

First to make sure our package is handling the command we need to specify that it needs to be auto loaded when we are connected to a team foundation server. We just need to add an attribute to our package as follows:

// initialize this package if team explorer is connected to a server
[ProvideAutoLoad("{e13eedef-b531-4afe-9725-28a69fa4f896}")] 
public sealed class WITContextMenuPackage : Package, IOleCommandTarget
    {

 

 

The highlighted line above is the context guid of Team Explorer connected to a server.

We also need to make our package derive from IOleCommandTarget so it can handle the command. Also, there might be some menu code in the Initialize(). Clear all code in that function. You can do some initialization here, but for the purposes of this example, this function should be empty:

protected override void Initialize()
    {
        base.Initialize();
    }

 

 

 

Package class has a GetService Method. We can use that to get various VS services, such as Team Explorer and DTE. The code is similar to the add-in example. Here are the helper methods to get the active document and SelectedNode in TE:

Partial listing of WITContextMenuPackage class:

public EnvDTE.DTE DTE
{
get
{
return this.GetService(typeof(EnvDTE.DTE)) as EnvDTE.DTE;
}
}

public IVsTeamExplorer TeamExplorer
{
get
{
return this.GetService(typeof(IVsTeamExplorer)) as IVsTeamExplorer;
}
}

public DocumentService DocService
{
get
{
return DTE.GetObject("Microsoft.VisualStudio.TeamFoundation.WorkItemTracking.DocumentService")
as DocumentService;
}
}

public IWorkItemTrackingDocument GetActiveDocument()
{
if (DTE.ActiveDocument != null)
{
string activeDocumentMoniker = DTE.ActiveDocument.FullName;
IWorkItemTrackingDocument doc = DocService.FindDocument(activeDocumentMoniker, _lockToken);
if (doc != null)
{
doc.Release(_lockToken);
}

return doc;
}

return null;
}

private string[] GetCurrentSelectedNodeInTE()
{
// Get the TE hierarchy and the selected itemid
IntPtr hier;
uint itemid;
IVsMultiItemSelect dummy;
TeamExplorer.TeamExplorerWindow.GetCurrentSelection(out hier, out itemid, out dummy);

IVsHierarchy hierarchy = (IVsHierarchy)Marshal.GetObjectForIUnknown(hier);
Marshal.Release(hier);

// now that we have the id and hierarchy, we can retrieve lots of properties about the node
// in this case, we get the canonical name which is the in the form of (server/project/[query folders]*/query)
string canonicalName;
hierarchy.GetCanonicalName(itemid, out canonicalName);

string[] tokens = canonicalName.Split('/');

return tokens;
}

Here is the implementation of IOleCommandTarget:

Partial listing of WITContextMenuPackage class:

int IOleCommandTarget.Exec(ref Guid pguidCmdGroup, uint nCmdID, uint nCmdexecopt, IntPtr pvaIn, IntPtr pvaOut)
{
if (pguidCmdGroup != GuidList.guidWITExtensibilityPkgCmdSet)
{
return OLECMDERR_E_UNKNOWNGROUP;
}

IWorkItemTrackingDocument document = GetActiveDocument();
IQueryDocument queryDoc = document as IQueryDocument;

switch (nCmdID)
{
case PkgCmdIDList.cmdidClearQuery:
if (queryDoc != null)
{
queryDoc.QueryText = "";
}
break;

case PkgCmdIDList.cmdidCopyQueryExpression:

string[] pathTokens = GetCurrentSelectedNodeInTE();

// now let’s get server/project and walk the hierarchy till we find the query definition of the selected item

TfsTeamProjectCollection tpc = TfsTeamProjectCollectionFactory.GetTeamProjectCollection(new Uri(TeamExplorer.GetProjectContext().DomainUri));

WorkItemStore store = tpc.GetService<WorkItemStore>();
Microsoft.TeamFoundation.WorkItemTracking.Client.Project proj = store.Projects[pathTokens[1]];
QueryItem qItem = proj.QueryHierarchy;
int currentTokenIndex = 2;
while (currentTokenIndex < pathTokens.Length)
{
qItem = (qItem as QueryFolder)[pathTokens[currentTokenIndex]];
currentTokenIndex++;
}

// we have the query definition, just copy the expression to the clipboard.
QueryDefinition def = qItem as QueryDefinition;
Clipboard.SetText(def.QueryText);

break;

default:
return OLECMDERR_E_UNKNOWNGROUP;
}

return VSConstants.S_OK;
}

int IOleCommandTarget.QueryStatus(ref Guid pguidCmdGroup, uint cCmds, OLECMD[] prgCmds, IntPtr pCmdText)
{
if (pguidCmdGroup != GuidList.guidWITExtensibilityPkgCmdSet)
{
return OLECMDERR_E_UNKNOWNGROUP;
}

IWorkItemTrackingDocument document = GetActiveDocument();
IQueryDocument queryDoc = document as IQueryDocument;

switch (prgCmds[0].cmdID)
{
case PkgCmdIDList.cmdidClearQuery:
// this command is in the context menu of query builder, so its always enabled.

// but if want it to be added to a main menu, or assign a shortcut to it, it should
// only be enabled if the active document is a query editor,
// the following code disable this command if we are not in a query editor

if (queryDoc != null)
{
// in a query document context, Enabled
prgCmds[0].cmdf = (int)OLECMDF.OLECMDF_SUPPORTED | (int)OLECMDF.OLECMDF_ENABLED;
}
else
{
// not in a query document, Disable
prgCmds[0].cmdf = (int)OLECMDF.OLECMDF_SUPPORTED;
}
break;

case PkgCmdIDList.cmdidCopyQueryExpression:
// we will always enable it since its always on the context menu of a query
prgCmds[0].cmdf = (int)OLECMDF.OLECMDF_SUPPORTED | (int)OLECMDF.OLECMDF_ENABLED;
break;

default:
return OLECMDERR_E_UNKNOWNGROUP;

}
return VSConstants.S_OK;
}