;

PathFinder - A Visual Studio add-in

Posted : Saturday, 26 September 2009 10:47:28

I spend most of my time working in my office at home and communicating with colleagues via some form or other of instant messenging protocol. Aside form the inevitable miscommunications that can occur from time to time (generally solved with judicicous use of smileys ;-)) I find this method of working to be quite productive. I'm less prone to distractions and feel better able to focus on a task.

One situation that arises fairly frequently when discussing with a colleague a particular aspect of a project we are both working is that I find myself making use of the rather handy "copy full path" feature of the visual studio tabbed environment (if you don't know what I mean, right click a tab and its one of the context menu options). Typically I'll paste the path into the IM window such that whoever I'm talking to can identify and open the file in question and we can look at the same code and discuss the best course of action etc.

While the "copy full path" feature is undoubtedly handy, it can sometimes be a bit of a chore to examine the path in the IM window, trace the path via the project structure and locate the file. It occurred to me that a handy feature of Visual Studio would be to take a supplied path and search through the current solution to find any matches for that path. An important consideration when designing this project was that local paths to the same file on each computer may (and usually does) vary so the add-in must be able to account for different physical file locations.

So this is my first foray into the world of Visual Studio Add-in development

The add-in

To Begin, create a new visual studio project and select "Visual Studio Add-in" template which can be found under "Other Project Types -> Extensibility", you will then be presented with a multi-step wizard. The first few steps of the wizard are fairly self explanatory, at step 4 however you will need to select add-in options.

step 4 of add-in wizard

Selecting the first checkbox will add a new item under the tools menu of the main visual studio toolbar. Follow the rest of the wizard accepting the defaults at each stage to create a new add-in project.

A visual studio add-in project is much the same as any other aside from a few subtle differences. For this add-in I only needed to alter the default auto-generated code in a couple of places. First I changed the default icon from the smiley face icon (in my opinion this looks pretty cheap) to something more appropriate. There are a number of stock icons that you can chose from - I spent a considerable amount of time trying to get a custom bitmap for my add-in but could not get it to work properly, according to the official documentation this can be acheived by adding the custom bitmap to a satellite assembly. I have read rumours that VS 2010 will do away with this seemingly over-engineered procedure so lets hope on that one! But I digress, I changed from the default smiley icon to the design mode icon by changing the following autogenerated code:

   77         //Add a command to the Commands collection:

   78         Command command = commands.AddNamedCommand2(

   79             _addInInstance,

   80             "PathFinder",

   81             "PathFinder",

   82             "Executes the command for PathFinder",

   83             true,

   84             59,

   85             ref contextGUIDS,

   86             (int)vsCommandStatus.vsCommandStatusSupported+(int)vsCommandStatus.vsCommandStatusEnabled,

   87             (int)vsCommandStyle.vsCommandStylePictAndText,

   88             vsCommandControlType.vsCommandControlTypeButton);

   89 

   90         //Add a control for the command to the tools menu:

   91         if((command != null) && (toolsPopup != null))

   92         {

   93             command.AddControl(toolsPopup.CommandBar, 1);

   94         }

to

   77         //Add a command to the Commands collection:

   78         Command command = commands.AddNamedCommand2(

   79             _addInInstance,

   80             "PathFinder",

   81             "PathFinder",

   82             "Executes the command for PathFinder",

   83             true,

   84             212,

   85             ref contextGUIDS,

   86             (int)vsCommandStatus.vsCommandStatusSupported+(int)vsCommandStatus.vsCommandStatusEnabled,

   87             (int)vsCommandStyle.vsCommandStylePictAndText,

   88             vsCommandControlType.vsCommandControlTypeButton);

   89 

   90         //Add a control for the command to the tools menu:

   91         if((command != null) && (toolsPopup != null))

   92         {

   93             command.AddControl(toolsPopup.CommandBar, 1);

   94         }

All that has changed is the 6th parameter(from 59 to 212) which indicates which of the stock Visual Studio icons will be used for the add-in. The purpose of the above line of code is to add a command to the Tools menu of the Visual Studio menu bar.

The second change I made to the auto generated code was to add the call to my custom code that will be executed when the add-in is run. When the the newly added command in the tools menu is selected, the Exec() method of the add-in class is fired. I inserted a single line of code to the this method which creates a new instance of a WPF window and passes into the constructor a reference to the current environment - this is provided by the project template and is an instance of the EnvDTE.DTE interface and acts as a handle to the current solution.

  159    public void Exec(string commandName, vsCommandExecOption executeOption, ref object varIn, ref object varOut, ref bool handled)

  160    {

  161        handled = false;

  162        if(executeOption == vsCommandExecOption.vsCommandExecOptionDoDefault)

  163        {

  164            if(commandName == "PathFinder.Connect.PathFinder")

  165            {

  166                InputWindow window = new InputWindow((DTE)_applicationObject);

  167                handled = true;

  168                return;

  169            }

  170        }

  171    }

Line 166 is the line I added. InputWindow is a class in a WPF project that contains most of the code for this add-in and is explained below.

The code

I decided to use WPF for the interface of this add-in, I took the WPF MCTS exam earlier in the year and haven't used it since so I thought I might as well take this opportunity to try using it in a real world example (rather than the Hello World type application that I wrote endless variations of while studying for the exam). I added a new WPF application to the solution and renamed the Window class that Visual Studios adds by default from Windox.xaml to InputWindow.xaml I then added a Label, a TextBox, a Button and a ListBox. These are all the functional elements required to construct the interface, I also added a couple of layout controls to place the interface controls correctly.

PathFinder interface

Once the interface was complete, I added a new constructor which, as you'll recall from above, accepts an instance of EnvDTE.DTE.

   27         public InputWindow(DTE applicationObject) : this()

   28         {

   29             ApplicationObject = applicationObject;

   30 

   31             if (System.Windows.Clipboard.ContainsText())

   32             {

   33                 this.textboxSource.Text = System.Windows.Clipboard.GetText();

   34             }

   35             this.ShowActivated = true;

   36             this.Show();

   37         }

During development I found that I could cut out a step in the test development cycle by automatically populating the text box with whatever text (if any) the clipboard currently contains. I also felt that this would prove an important UI boost during real world usage as the typical scenario in which I envisage using this add-in would be to copy a path passed via an IM window and paste into the add-in form. The approach I am taking to compare paths is to break each path into indivdual segments, one segment for each folder in the path and one segment for the file at the end of the path. As a solution can easily contain multiple files with the same name (for example an ASP.NET application can contain many web.config files) I will implement a basic ranking algorthym for any matched files. So the first step required to provide ranked match results is to search the current solution for all files with the same as that being sought (eg the rightmost segment of the supplied path). I acheived this using three methods ExamineSolution, ExamineProject and ExamineProjectItem - the latter being a recursive function that will iterate through every file and folder in each project. To store found matches I created a class called MatchedProjectItem that holds a reference to any matched ProjectItems. Each instance of MatchedProjectItem is stored in a module level collection which, once the iterative search has been completed, can be analysed and ranked. Incidentally the module-level collection is an instance of List <IMatchedItem> where IMatchedItem is an interface I created that allows me to easily account for searches where no matches are found, this will be explained in more detail below.

Now I have my collection of matched files, its time to compare the paths and rank them. To do this I created a class called PathMap which extends the generic List<> class and is strongly typed on another class I created called PathSegment. The constructor of the PathMap class accepts a string single string parameter which is whatever is contained in the TextBox of the add-in window and breaks it up into PathSegments

   10         public PathMap(string path)

   11         {

   12             StringBuilder sb = new StringBuilder(path);

   13             while (sb.Length > 0)

   14             {

   15                 string s = sb.ToString();

   16                 int startPos = s.Contains(@"\") ? s.LastIndexOf(@"\") : 0;

   17                 int len = s.Length - startPos;

   18                 string segment = s.Substring(startPos, len).Replace(@"\", "");

   19                 this.Add(new PathSegment() { SegmentName = segment.ToLower() });

   20                 sb.Remove(startPos, len);

   21             }

   22         }

I then added a method to the PathMap class that accepts a single string parameter representing the full path of the matched file name found during the iterative search process above. The purpose of this method is to compare the two paths and return an integer representing the percentage match of the supplied path to the originally sought path. This is only a basic matching algorthym and I'm sure can will be improved upon. For each match found I call this method and store the result in the MatchedProjectItem class. I wanted to feedback to the user if no matches are found in as unotrusive a way as possible (eg without using Messagebox) so I created a dummy implementation of IMatchedItem that I could add to the matches collection and use that as a means to inform the user that no matches have been found. It is then a simple Linq query to order the matches by percent match set the ItemsSource property of the ListBox to the results of this Linq query.

   39         private void ButtonSearch_Click(object sender, RoutedEventArgs e)

   40         {

   41             TargetPath = textboxSource.Text;

   42 

   43             //obtain file name of supplied path

   44             TargetFile = PathMap.GetRightmostSegment(TargetPath);

   45 

   46             //clear the results of any previous searches

   47             Matches.Clear();

   48 

   49             //examine every file in the solution for matches

   50             ExamineSolution();

   51 

   52             if (Matches.Count == 0)

   53             {

   54                 NoMatchItem dummy = new NoMatchItem();

   55                 Matches.Add(dummy);

   56             }

   57             else

   58             {

   59                 //create PathMap from supplied path

   60                 PathMap sourcePath = new PathMap(TargetPath);

   61                 //compare full paths of any matches to that of source

   62                 foreach (var match in Matches)

   63                 {

   64                     match.PercentMatch = sourcePath.MatchPath(match.FullPath);

   65                 }

   66             }

   67             //show results

   68             listboxmatches.ItemsSource = from match in Matches

   69                                         orderby match.PercentMatch descending

   70                                         select match;

   71         }

The results of a successful search should look something like this

PathFinder with results

The final step is to allow the user to navigate to any matches - I acheived this by implementing an event handler on the ListBox as follows

  139         private void listboxmatches_MouseDoubleClick(object sender, MouseButtonEventArgs e)

  140         {

  141             var matchedItem = ((ListBox)sender).SelectedItem as MatchedProjectItem;

  142             if (matchedItem != null)

  143             {

  144                 matchedItem.projectItem.Open(EnvDTE.Constants.vsViewKindCode).Activate();

  145                 this.Hide();

  146             }

  147         }

This is pretty simple and doesn't really warrant a detailed explanation suffice to say it seemed reasonable to me to close the add-in window when a match is selected as personally I dont like loads of windows cluttering up my workspace - it's easy enough to change this or possibly even add a check box to the form to indicate that the window should be closed when selecting a match.

This is a basic example of creating a Visual Studio add-in and has massive scope for development, improved matching and configurable options to name but two. My intention is to use this add-in during my typical workday and refine it as necessary, I may blog about refinements in the future. Please let me know any improvements you think worthwhile.

  • (This will not appear on the site)