Adding your own help functionality to Visual Studio F1 Help

Akos Nagy
Jul 22, 2019

Not long ago a participant at a course of mine asked an interesting question. They were working on a project that used a .NET API extensively and they had developed their own set of internal documentation. The requirement was simple: you know the simple help feature of Visual Studio where you press F1 when a cursor is on a keyword, class or method and the corresponding documentation pops up in your browser? Well, they wanted their own documentation to pop up in the browser instead of the official docs.microsoft.com. The idea was intriguing, and while I did have some ideas to work around the problem (like setting up a proxy and catching those urls and redirect the user), I wanted to give a proper solution using a Visual Studio Extension. Finally I had the time, so here you go :) This is just a proof-of-concept, but it does work. Maybe later down the line I might create an actual extension that allows you to set the "help-handler".

Creating the extension

So first thing's first: I simply created an empty VSiX project, because I didn't really know what else to do. And then, I set out to Google the hell out of the problem, like a good engineer should :) First, I found a similar question on Stackoverflow with a partial answer here. Unfortunately, it was not a complete answer. The article is very old, and the then-author of the post admits that even at that time his knowledge was probably out of date. And boy was he right :) While it provided a good starting point, I just couldn't figure out how to integrate this stuff into Visual Studio (or if it was even possible with the current version of the extension SDK).

So while this half seemed to have some potential, I first had to find a way to integrate this (or at least try to integrate this) into Visual Studio. I switched up my approach and I decided that I should first try to figure out how to hook onto the commands of the IDE. After some more Googling, I found this question that shows how you can access the registered commands of the IDE and add your own event handlers to them. I have created Visual Studio extensions before (you can check them out on the marketplace), and this seemed to be a usable solution. So all I had to do was find the right command GUID and ID (that was no problem with my previous experience) and then I created this code in my extension:

protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
{
  await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken);
  await InitSearchExtensionsAsync();
}

public async Task InitSearchExtensionsAsync()
{
  var dte = (DTE2)(await GetServiceAsync(typeof(DTE)));
  var helpEvent = dte.Events.CommandEvents[typeof(VSConstants.VSStd97CmdID).GUID.ToString("B"), (int)VSConstants.VSStd97CmdID.F1Help];
  helpEvent.AfterExecute += OnAfterExecute;
}

private void OnAfterExecute(string Guid, int ID, object CustomIn, object CustomOut)
{
  Debugger.Break();
}

And believe it or not, this didn't work :) Again after some trial and error, I figured out the helpEvent should be a field and not a local variable. I thought that a indexer would return an element to the dictionary and the dictionary would hold a reference to that object as long as Visual Studio was running. I still don't know what happens and why must I place it into a field and why doesn't the indexer return the same object reference to the dictionary element every time, but when I switched to a field, it started working :) So the final solution looks like this (also note that I added the [ProvideAutoLoad(UiContextGuids80.SolutionExists)] attribute to the extension so that it would start loading asynchronously after a solution is opened in the IDE):

 [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[ProvideAutoLoad(UIContextGuids80.SolutionExists, PackageAutoLoadFlags.BackgroundLoad)]
[Guid(PackageGuidString)]
public sealed class VSIXProject2Package : AsyncPackage
{
  private CommandEvents helpEvent;
  private DTE2 dte;

  public const string PackageGuidString = "ea8df1a1-f317-47ab-bb37-c8aeac574ab0";
  
  protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
  {
    await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken);
    await InitSearchExtensionsAsync();
  }

  public async Task InitSearchExtensionsAsync()
  {
    dte = (DTE2)(await GetServiceAsync(typeof(DTE)));
    helpEvent = dte.Events.CommandEvents[typeof(VSConstants.VSStd97CmdID).GUID.ToString("B"), (int)VSConstants.VSStd97CmdID.F1Help];
    helpEvent.AfterExecute += OnAfterExecute;
  }

  private void OnAfterExecute(string Guid, int ID, object CustomIn, object CustomOut)
  {
    Debugger.Break();        
  }
}

Integrating my own search logic

To integrate my own search logic, I looked over the first StackOverflow answer and I came up with this little piece of code for the event handler method:

private void OnAfterExecute(string Guid, int ID, object CustomIn, object CustomOut)
{
  var activeWindow = dte.ActiveWindow;
  ContextAttributes contextAttributes = activeWindow.DTE.ContextAttributes;
  if (contextAttributes == null)
    return;
    
  contextAttributes.Refresh();                   
  contextAttributes.HighPriorityAttributes?.Refresh();            
  var attributes = contextAttributes.Cast<ContextAttribute>()                                            .Union(contextAttributes.HighPriorityAttributes?.Cast<ContextAttribute>() ?? Enumerable.Empty<ContextAttribute>())
                  .ToDictionary(ca => ca.Name, ca => ((object[])ca.Values).Cast<string>().ToList());

  ProcessHelpContext(attributes);
}

This method basically creates a lookup, where the keys of the property bag from the aforementioned StackOverflow answer are the keys and the values of the property bag are the values of the dictionary. If you look at this with the debugger, you can simply take a look at the keys and values in the dictionary and decide how to use them. In this little example I simply called my ProcessHelpContext() method that initiates a Google search for the keyword that is under the cursor when F1 is pressed and appends the current programming language (i.e. csharp) to the query:

private void ProcessHelpContext(Dictionary<string, List<string>> attributes)
{
  System.Diagnostics.Process.Start($"http://google.com/search?q={attributes["keyword"][0]} {attributes["product"][1]}");
}

And that's it. Note that this leaves the original F1 Help feature untouched, so the original documentation also pops up in your browser. But on a second tab, you get the results of the Google search as well. Nice :) (Also note that you do have to wait a little after opening the solution for the extension to fully load; keep an eye on the progress icon in the bottom left corner of you Visual Studio).

IF you want to build the project yourself, chekc out the source code on Github

Akos Nagy
Posted in Visual Studio