How to Control Visual Studio from an external application

 
 
  • Gérald Barré

There are multiple use cases where you need to get information from running instances of Visual Studio. For instance, if you create a git client, you may suggest the repositories that correspond to the opened solution, or you want to know if a file is not saved in the editor and show a warning. Maybe you need to close the current solution and open a new one. All of this is possible with Visual Studio as it exposes COM interfaces!

Let's create a console application that displays the list of opened Visual Studio instances with their version and currently opened solutions.

Shell
dotnet new console

First, you need to add the EnvDTE80 package. This package contains the code to interact with Visual Studio.

Shell
dotnet add package EnvDTE80

The csproj file should look like the following:

csproj (MSBuild project file)
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="EnvDTE80" Version="17.2.32505.113" />
  </ItemGroup>

</Project>

Then, you can enumerate the opened Visual Studio instances by using the GetRunningObjectTable method. This method lists registered COM instances. Visual Studio registers its instances in the RunningObjectTable COM object. For each moniker from this table, you can get the name of the object by using CreateBindCtx and GetDisplayName. In the case of Visual Studio, the moniker name is !VisualStudio.DTE.{Version}.

VisualStudioInstance.cs (C#)
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Runtime.Versioning;
using EnvDTE;
using EnvDTE80;
using Thread = System.Threading.Thread;

[SupportedOSPlatform("windows5.0")]
internal sealed partial class VisualStudioInstance
{
    private const int MaxRetryCount = 10;
    private const int SleepTimeAtEachRetry = 10;
    const string DteName = "!VisualStudio.DTE.";

    private const uint S_OK = 0;

    private readonly DTE2 _dte2;

    private VisualStudioInstance(DTE2 dte2)
    {
        _dte2 = dte2 ?? throw new ArgumentNullException(nameof(dte2));
    }

    public static IEnumerable<VisualStudioInstance> GetInstances()
    {
        var uret = GetRunningObjectTable(0, out IRunningObjectTable runningObjectTable);
        if (uret != S_OK)
            yield break;

        runningObjectTable.EnumRunning(out IEnumMoniker monikerEnumerator);
        if (monikerEnumerator != null)
        {
            foreach (IMoniker moniker in EnumerateMonikers(monikerEnumerator))
            {
                try
                {
                    var bindContextResult = CreateBindCtx(0, out IBindCtx ctx);
                    if (bindContextResult != S_OK)
                        continue;

                    moniker.GetDisplayName(ctx, null, out var objectName);
                    Marshal.ReleaseComObject(ctx);

                    if (objectName.StartsWith(DteName, StringComparison.Ordinal))
                    {
                        var getObjectResult = runningObjectTable.GetObject(moniker, out var temp);
                        if (getObjectResult == S_OK)
                        {
                            var dte = (DTE2)temp;
                            VisualStudioInstance? instance = null;
                            try
                            {
                                if (dte != null)
                                {
                                    _ = dte.FileName; // dummy call to ensure DTE is responsive
                                    instance = new VisualStudioInstance(dte);
                                }
                            }
                            catch
                            {
                                instance = null;
                            }

                            if (instance != null)
                            {
                                yield return instance;
                            }
                        }
                    }
                }
                finally
                {
                    Marshal.ReleaseComObject(moniker);
                }
            }
        }
    }

    private static IEnumerable<IMoniker> EnumerateMonikers(IEnumMoniker enumerator)
    {
        const int MonikerBunchSize = 10;
        var monikers = new IMoniker[MonikerBunchSize];
        IntPtr fetchCountReference = Marshal.AllocHGlobal(sizeof(int));
        try
        {
            int nextResult;
            do
            {
                nextResult = enumerator.Next(MonikerBunchSize, monikers, fetchCountReference);
                var fetchCount = Marshal.ReadInt32(fetchCountReference);

                for (var i = 0; i < fetchCount; i++)
                {
                    yield return monikers[i];
                }
            }
            while (nextResult == S_OK);
        }
        finally
        {
            Marshal.FreeHGlobal(fetchCountReference);
        }
    }

    [DllImport("ole32.dll", EntryPoint = "GetRunningObjectTable")]
    private static extern uint GetRunningObjectTable(uint res, out IRunningObjectTable runningObjectTable);

    [DllImport("ole32.dll", EntryPoint = "CreateBindCtx")]
    private static extern uint CreateBindCtx(uint res, out IBindCtx ctx);
}

You can extend the VisualStudioInstance class with helper methods to interact with the DTE2 instance. Here are some useful examples:

VisualStudioInstance.cs (C#)
internal sealed partial class VisualStudioInstance
{
    public string? SolutionFullPath
    {
        get
        {
            if (TryExecuteDevEnvCommand(() => _dte2.Solution.FullName, out var fullName) && !string.IsNullOrEmpty(fullName))
                return fullName;

            return null;
        }
    }

    public string? Version
    {
        get
        {
            if (TryExecuteDevEnvCommand(() => _dte2.Version, out var version))
                return version;

            return null;
        }
    }

    public bool TryOpenSolution(string solutionPath)
    {
        return TryExecuteDevEnvCommand(() => _dte2.Solution.Open(solutionPath));
    }

    public bool TryCloseSolution()
    {
        return TryExecuteDevEnvCommand(() => _dte2.Solution.Close(SaveFirst: true));
    }

    public bool HasUnsavedChanges()
    {
        foreach (Document item in _dte2.Documents)
        {
            if (!item.Saved)
                return true;
        }

        return false;
    }

    private static bool TryExecuteDevEnvCommand(Action devEnvCommand)
    {
        return TryExecuteDevEnvCommand(() =>
        {
            devEnvCommand();
            return 0;
        }, out _);
    }

    private static bool TryExecuteDevEnvCommand<T>(Func<T> devEnvCommand, out T? value)
    {
        var count = 0;
        while (count++ < MaxRetryCount)
        {
            try
            {
                value = devEnvCommand();
                return true;
            }
            catch (COMException)
            {
                Thread.Sleep(SleepTimeAtEachRetry);
            }
            catch (InvalidCastException)
            {
                break;
            }
        }

        value = default;
        return false;
    }
}

Finally, you can update the Program.cs file to use the VisualStudioInstance class:

Program.cs (C#)
foreach (var instance in VisualStudioInstance.GetInstances().ToArray())
{
    Console.Write($"{instance.Version}: {instance.SolutionFullPath}");
    if (instance.HasUnsavedChanges())
    {
        Console.Write(" *");
    }

    Console.WriteLine();
}

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub