// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.ComponentModel.Composition;
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Experimentation;
using Microsoft.CodeAnalysis.Experiments;
using Microsoft.CodeAnalysis.Extensions;
using Microsoft.VisualStudio.LanguageServices.Experimentation;
using Microsoft.VisualStudio.LanguageServices.Implementation.Utilities;
using Microsoft.VisualStudio.LanguageServices.Utilities;
using Microsoft.VisualStudio.OLE.Interop;
using Microsoft.VisualStudio.PlatformUI.OleComponentSupport;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Roslyn.Utilities;
using Task = System.Threading.Tasks.Task;
namespace Microsoft.VisualStudio.LanguageServices.Implementation.Experimentation
{
///
/// Detects if keybindings have been messed up by ReSharper disable, and offers the user the ability
/// to reset if so.
///
///
/// The only objects to hold permanent references to this object should be callbacks that are registered for in
/// . No other external objects should hold a reference to this. Unless the user clicks
/// 'Never show this again', this will persist for the life of the VS instance, and does not need to be manually disposed
/// in that case.
///
[Export(typeof(IExperiment))]
internal sealed class KeybindingResetDetector : ForegroundThreadAffinitizedObject, IExperiment, IOleCommandTarget
{
// Flight info
private const string InternalFlightName = "keybindgoldbarint";
private const string ExternalFlightName = "keybindgoldbarext";
private const string KeybindingsFwLink = "https://go.microsoft.com/fwlink/?linkid=864209";
// Resharper commands and package
private const uint ResumeId = 707;
private const uint SuspendId = 708;
private const uint ToggleSuspendId = 709;
private static readonly Guid ReSharperPackageGuid = new Guid("0C6E6407-13FC-4878-869A-C8B4016C57FE");
private static readonly Guid ReSharperCommandGroup = new Guid("{47F03277-5055-4922-899C-0F7F30D26BF1}");
private readonly VisualStudioWorkspace _workspace;
private readonly System.IServiceProvider _serviceProvider;
// All mutable fields are UI-thread affinitized
private IExperimentationService _experimentationService;
private IVsUIShell _uiShell;
private IOleCommandTarget _oleCommandTarget;
private OleComponent _oleComponent;
private uint _priorityCommandTargetCookie = VSConstants.VSCOOKIE_NIL;
///
/// If false, ReSharper is either not installed, or has been disabled in the extension manager.
/// If true, the ReSharper extension is enabled. ReSharper's internal status could be either suspended or enabled.
///
private bool _resharperExtensionEnabled = false;
private bool _infoBarOpen = false;
[ImportingConstructor]
public KeybindingResetDetector(VisualStudioWorkspace workspace, SVsServiceProvider serviceProvider)
{
_workspace = workspace;
_serviceProvider = serviceProvider;
}
public Task InitializeAsync()
{
// Immediately bail if the user has asked to never see this bar again.
if (_workspace.Options.GetOption(KeybindingResetOptions.NeverShowAgain))
{
return Task.CompletedTask;
}
return InvokeBelowInputPriority(InitializeCore);
}
private void InitializeCore()
{
AssertIsForeground();
// Ensure one of the flights is enabled, otherwise bail
_experimentationService = _workspace.Services.GetRequiredService();
if (!_experimentationService.IsExperimentEnabled(ExternalFlightName) && !_experimentationService.IsExperimentEnabled(InternalFlightName))
{
return;
}
var vsShell = _serviceProvider.GetService();
var hr = vsShell.IsPackageInstalled(ReSharperPackageGuid, out int extensionEnabled);
if (ErrorHandler.Failed(hr))
{
FatalError.ReportWithoutCrash(Marshal.GetExceptionForHR(hr));
return;
}
_resharperExtensionEnabled = extensionEnabled != 0;
if (_resharperExtensionEnabled)
{
// We need to monitor for suspend/resume commands, so create and install the command target and the modal callback.
var priorityCommandTargetRegistrar = _serviceProvider.GetService();
hr = priorityCommandTargetRegistrar.RegisterPriorityCommandTarget(
dwReserved: 0 /* from docs must be 0 */,
pCmdTrgt: this,
pdwCookie: out _priorityCommandTargetCookie);
if (ErrorHandler.Failed(hr))
{
FatalError.ReportWithoutCrash(Marshal.GetExceptionForHR(hr));
return;
}
// Initialize the OleComponent to listen for modal changes (which will tell us when Tools->Options is closed)
_oleComponent = OleComponent.CreateHostedComponent("Keybinding Reset Detector");
_oleComponent.ModalStateChanged += OnModalStateChanged;
}
UpdateStateMachine();
}
private void UpdateStateMachine()
{
AssertIsForeground();
var currentStatus = IsReSharperEnabled();
var options = _workspace.Options;
ReSharperStatus lastStatus = options.GetOption(KeybindingResetOptions.ReSharperStatus);
options = options.WithChangedOption(KeybindingResetOptions.ReSharperStatus, currentStatus);
switch (lastStatus)
{
case ReSharperStatus.NotInstalledOrDisabled:
case ReSharperStatus.Suspended:
if (currentStatus == ReSharperStatus.Enabled)
{
// N->E or S->E. If ReSharper was just installed and is enabled, reset NeedsReset.
options = options.WithChangedOption(KeybindingResetOptions.NeedsReset, false);
}
// Else is N->N, N->S, S->N, S->S. N->S can occur if the user suspends ReSharper, then disables
// the extension, then reenables the extension. We will show the gold bar after the switch
// if there is still a pending show.
break;
case ReSharperStatus.Enabled:
if (currentStatus != ReSharperStatus.Enabled)
{
// E->N or E->S. Set NeedsReset. Pop the gold bar to the user.
options = options.WithChangedOption(KeybindingResetOptions.NeedsReset, true);
}
// Else is E->E. No actions to take
break;
}
_workspace.Options = options;
if (options.GetOption(KeybindingResetOptions.NeedsReset))
{
ShowGoldBar();
}
}
private void ShowGoldBar()
{
AssertIsForeground();
// If the gold bar is already open, do not show
if (_infoBarOpen)
{
return;
}
_infoBarOpen = true;
string message;
if (_experimentationService.IsExperimentEnabled(InternalFlightName))
{
message = ServicesVSResources.We_noticed_you_suspended_ReSharper_Ultimate_Restore_Visual_Studio_keybindings_to_continue_to_navigate_and_refactor;
}
else if (_experimentationService.IsExperimentEnabled(ExternalFlightName))
{
message = ServicesVSResources.Your_keybindings_are_no_longer_mapped_to_Visual_Studio_commands;
}
else
{
// Should never have gotten to checking this if one of the flights isn't enabled.
throw ExceptionUtilities.Unreachable;
}
KeybindingsResetLogger.Log("InfoBarShown");
var infoBarService = _workspace.Services.GetRequiredService();
infoBarService.ShowInfoBarInGlobalView(
message,
new InfoBarUI(title: ServicesVSResources.Restore_Visual_Studio_keybindings,
kind: InfoBarUI.UIKind.Button,
action: RestoreVsKeybindings),
new InfoBarUI(title: ServicesVSResources.Use_Keybindings_for_extensions,
kind: InfoBarUI.UIKind.Button,
action: OpenExtensionsHyperlink),
new InfoBarUI(title: ServicesVSResources.Never_show_this_again,
kind: InfoBarUI.UIKind.HyperLink,
action: NeverShowAgain),
new InfoBarUI(title: "", kind: InfoBarUI.UIKind.Close,
action: InfoBarClose));
}
private ReSharperStatus IsReSharperEnabled()
{
AssertIsForeground();
// Quick exit if resharper is either uninstalled or not enabled
if (!_resharperExtensionEnabled)
{
return ReSharperStatus.NotInstalledOrDisabled;
}
if (_oleCommandTarget == null)
{
_oleCommandTarget = _serviceProvider.GetService();
}
var cmds = new OLECMD[1];
cmds[0].cmdID = SuspendId;
cmds[0].cmdf = 0;
var hr = _oleCommandTarget.QueryStatus(ReSharperCommandGroup, (uint)cmds.Length, cmds, IntPtr.Zero);
if (ErrorHandler.Failed(hr))
{
// In the case of an error when attempting to get the status, pretend that ReSharper isn't enabled. We also
// shut down monitoring so we don't keep hitting this.
FatalError.ReportWithoutCrash(Marshal.GetExceptionForHR(hr));
Shutdown();
return ReSharperStatus.NotInstalledOrDisabled;
}
// When ReSharper is enabled, the ReSharper_Suspend command has the Enabled | Supported flags. When disabled, it has Invisible | Supported.
return ((OLECMDF)cmds[0].cmdf).HasFlag(OLECMDF.OLECMDF_ENABLED) ? ReSharperStatus.Enabled : ReSharperStatus.Suspended;
}
private void RestoreVsKeybindings()
{
AssertIsForeground();
if (_uiShell == null)
{
_uiShell = _serviceProvider.GetService();
}
ErrorHandler.ThrowOnFailure(_uiShell.PostExecCommand(
VSConstants.GUID_VSStandardCommandSet97,
(uint)VSConstants.VSStd97CmdID.CustomizeKeyboard,
(uint)OLECMDEXECOPT.OLECMDEXECOPT_DODEFAULT,
null));
KeybindingsResetLogger.Log("KeybindingsReset");
_workspace.Options = _workspace.Options.WithChangedOption(KeybindingResetOptions.NeedsReset, false);
}
private void OpenExtensionsHyperlink()
{
ThisCanBeCalledOnAnyThread();
if (!BrowserHelper.TryGetUri(KeybindingsFwLink, out Uri fwLink))
{
// We're providing a constant, known-good link. This should be impossible.
throw ExceptionUtilities.Unreachable;
}
BrowserHelper.StartBrowser(fwLink);
KeybindingsResetLogger.Log("ExtensionsLink");
_workspace.Options = _workspace.Options.WithChangedOption(KeybindingResetOptions.NeedsReset, false);
}
private void NeverShowAgain()
{
AssertIsForeground();
_workspace.Options = _workspace.Options.WithChangedOption(KeybindingResetOptions.NeverShowAgain, true)
.WithChangedOption(KeybindingResetOptions.NeedsReset, false);
KeybindingsResetLogger.Log("NeverShowAgain");
// The only external references to this object are as callbacks, which are removed by the Shutdown method.
Shutdown();
}
private void InfoBarClose()
{
AssertIsForeground();
_infoBarOpen = false;
}
public int QueryStatus(ref Guid pguidCmdGroup, uint cCmds, OLECMD[] prgCmds, IntPtr pCmdText)
{
// Technically can be called on any thread, though VS will only ever call it on the UI thread.
ThisCanBeCalledOnAnyThread();
// We don't care about query status, only when the command is actually executed
return (int)OLE.Interop.Constants.OLECMDERR_E_NOTSUPPORTED;
}
public int Exec(ref Guid pguidCmdGroup, uint nCmdID, uint nCmdexecopt, IntPtr pvaIn, IntPtr pvaOut)
{
// Technically can be called on any thread, though VS will only ever call it on the UI thread.
ThisCanBeCalledOnAnyThread();
if (pguidCmdGroup == ReSharperCommandGroup && nCmdID >= ResumeId && nCmdID <= ToggleSuspendId)
{
// Don't delay command processing to update resharper status
Task.Run(() => InvokeBelowInputPriority(UpdateStateMachine));
}
// No matter the command, we never actually want to respond to it, so always return not supported. We're just monitoring.
return (int)OLE.Interop.Constants.OLECMDERR_E_NOTSUPPORTED;
}
private void OnModalStateChanged(object sender, StateChangedEventArgs args)
{
ThisCanBeCalledOnAnyThread();
// Only monitor for StateTransitionType.Exit. This will be fired when the shell is leaving a modal state, including
// Tools->Options being exited. This will fire more than just on Options close, but there's no harm from running an
// extra QueryStatus.
if (args.TransitionType == StateTransitionType.Exit)
{
InvokeBelowInputPriority(UpdateStateMachine);
}
}
public void Shutdown()
{
AssertIsForeground();
if (_priorityCommandTargetCookie != VSConstants.VSCOOKIE_NIL)
{
var priorityCommandTargetRegistrar = _serviceProvider.GetService();
var cookie = _priorityCommandTargetCookie;
_priorityCommandTargetCookie = VSConstants.VSCOOKIE_NIL;
var hr = priorityCommandTargetRegistrar.UnregisterPriorityCommandTarget(cookie);
if (ErrorHandler.Failed(hr))
{
FatalError.ReportWithoutCrash(Marshal.GetExceptionForHR(hr));
}
}
if (_oleComponent != null)
{
_oleComponent.Dispose();
_oleComponent = null;
}
}
}
}