Visual Studio 注释扩展

在阅读大型工程源代码的时候经常需要根据自己的理解为代码添加某些注释,直接在源码文件中的修改有可能会导致工程重新编译,git 更新代码时产生冲突等情况,更有甚者一旦代码通过 git 更新之后,自己添加的注释将不会出现在新代码中;如果将注释写在源码之外的文件中,在以后查阅和对照时又会有其他新的麻烦。基于以上需求,笔者实现了一个 Visual Studio 中的注释插件,可以在源码文件之外独立添加注释,并且在查看源码时会在对应的位置显示注释。

效果如图所示

在编写过程中参考了这篇文章 中插件的实现。感谢这位作者的分享。

Visual Studio Extension

微软为 vs 扩展的开发者提供了一套完整的开发文档,以便开发者可以根据自己的需求扩展 Visual Studio 的功能。理论上而言可用的扩展点如下

  • 命令和菜单
  • 工具窗口
  • 编辑器窗口
  • 语言服务
  • 项目扩展
  • 用户设置
  • 属性和属性窗口
  • Visual Studio 独立 Shell

其他更多关于 VS 扩展的内容可以参见上面的开发文档,其中提供了大量的例子可以参考。

实现

下面笔者将一步一步记录下整个插件的编写过程,以便之后改进和复现。

安装 Visual Studio SDK

Visual Studio SDK 是编写扩展的必要条件。有四种方式可以安装 SDK:

  • 一、在安装 VS 的时候选择自定义安装,选中 “Visual Studio 扩展性工具” 选项;
  • 二、如果已经安装了 VS,在 “控制面板 / 程序 / 程序和功能” 中选择更改 VS;
  • 三、打开一个 Extension 项目,会有提示,安装 VSSDK;
  • 四、直接通过命令行 vs_community.exe /s /installSelectableItems VS_SDK_GROUPV1

实现用户自定义命令

插件应该接收到用户的操作请求开始。于是,第一步我们先实现一个用户自定义命令(Custom Command),可以参考链接:使用菜单命令创建扩展

新建一个 VSIX 工程

1

我们实现的是一个基于 VsPackage 的 Extension,于是首先添加一个 VsPackage 作为 Extension 的主体
9

需要注意的是,在这个过程中有可能出现 can't find "key.snk" 这样的错误,这是由于 VS 的权限不够造成的,需要以管理员权限打开 VS 才能添加成功。这里是微软的解释。

接着在新生成的工程中右键添加项目“自定义命令”

2

当添加成功之后将会产生几个新文件,其中 command.cs 文件用于实现这个 “自定义命令” 的逻辑,可以看到根据模板这里已经预先创建了很多方法。这个 C# 文件以 Command 构造函数为入口注册插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static readonly Guid CommandSet = new Guid("e9f4d902-8066-48f4-89a3-a8e0eb96a760");
public const int CommandId = 256;
private Command(Package package)
{
if (package == null)
{
throw new ArgumentNullException("package");
}
this.package = package;
OleMenuCommandService commandService = this.ServiceProvider.GetService(typeof(IMenuCommandService)) as OleMenuCommandService;
if (commandService != null)
{
var menuCommandID = new CommandID(CommandSet, CommandId);
MenuCommand menuItem = new MenuCommand(this.MenuItemCallback, menuCommandID);
commandService.AddCommand(menuItem);
}
}

其中 MenuItemCallback 是注册的命令触发之后的回调函数,可以修改这个函数以添加自定义功能。这里的测试效果是打开一个记事本

1
2
3
4
5
6
private void MenuItemCallback(object sender, EventArgs e)
{
Process proc = new Process();
proc.StartInfo.FileName = "notepad.exe";
proc.Start();
}

编译调试,可以在调试界面中看到这个插件

插件选项卡之所以会出现 Tools 菜单下拉菜单中是由同目录下的 package.vsct 文件决定的,可以通过修改这个文件改变这个选项卡的显示位置。

一个完整的用户自定义命令包含如下部分

  • IDSymbol :symbol 是一个命令的唯一标识,用于标识这个命令,这个 id 会在对应 command 的 cs 文件中注册

    1
    2
    3
    4
    <GuidSymbol name="guidEnableCommentCommandPackageCmdSet" value="{e9f4d902-8066-48f4-89a3-a8e0eb96a760}">
    <IDSymbol name="MyMenuGroup" value="0x1020" />
    <IDSymbol name="EnableCommentCommandId" value="0x0100" />
    </GuidSymbol>
  • Group group 用于定义分组,图中以横线分隔的部分就是一个分组

    1
    2
    3
    <Group guid="guidEnableCommentCommandPackageCmdSet" id="MyMenuGroup" priority="0x0600">
    <Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS"/>
    </Group>

  • Button button 用于定义按钮,其中 Icon 标签可以指定按钮的图标,Parent 用于指定 button 在哪个 Group 中,guid 和 id 一定要和 Group 中的完全一致。id 属性即为对应的 cs 命令
    1
    2
    3
    4
    5
    6
    7
    <Button guid="guidEnableCommentCommandPackageCmdSet" id="EnableCommentCommandId" priority="0x0100" type="Button">
    <Parent guid="guidEnableCommentCommandPackageCmdSet" id="MyMenuGroup" />
    <Icon guid="guidImages" id="bmpPic1" />
    <Strings>
    <ButtonText>Enable\Disable SmartCommand</ButtonText>
    </Strings>
    </Button>

默认情况下新增的自定义命令在一个新建的自定义 Group 中,这个 Group 的 Parent 属性被设置为 IDM_VS_MENU_TOOLS 即 Tools 下拉菜单。

我们想要修改这个位置,使其出现在上方菜单中,首先在 GuidSymbol 一栏添加新菜单项, Value 值为唯一注册的 ID

1
2
3
4
<GuidSymbol name="guidSmartCommentCmdSet">
....
<IDSymbol name="TopLevelMenu" value="0x1021"/>
</GuidSymbol >

接着在 Commands 一栏中添加新的 Menu 配置,创建新的下拉菜单。其中 guid 为这个命令的唯一标识符,对应之前注册的 IDSymbol->name,Parent 这里指向顶部菜单,String 为这个顶部菜单中button 的字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
<Commands>
...
<Menus>
<Menu guid="guidSmartCommentCmdSet" id="TopLevelMenu" priority="0x700" type="Menu">
<Parent guid="guidSHLMainMenu" id="IDG_VS_MM_TOOLSADDINS" />
<Strings>
<ButtonText>TestMenu</ButtonText>
<CommandName>SmartComment</CommandName>
</Strings>
</Menu>
</Menus>
...
</Commands>

接着修改 Group 设置,将 Group 的 parent 属性修改为新建的 Menu

1
2
3
4
5
<Groups>
<Group guid="guidSmartCommentCmdSet" id="MyMenuGroup" priority="0x0600">
<Parent guid="guidSmartCommentCmdSet" id="TopLevelMenu"/>
</Group>
</Groups>

此时插件选项卡便会出现在顶部菜单中。

这个自定义命令的目的是为了控制随时启用或者禁用插件功能,那么便需要一个全局配置文件配合,通过配置文件来管理整个插件的行为。为这个插件创建配置文件。添加新内容 Settings File

在这个 Setting 中添加一个全局属性 EnableComment。

接着我们为之前的自定义功能添加 CheckBox,修改 cs 中的 MeneItemCallback 函数,使得点击操作可以修改 Setting 中配置的 EnableComment

1
2
3
4
GeneralSettings.Default.EnableComment = !GeneralSettings.Default.EnableComment;
GeneralSettings.Default.Save();
var command = sender as MenuCommand;
command.Checked = GeneralSettings.Default.EnableComment;

同时需要在函数注册时将菜单选项卡与 EnableComment 绑定起来

1
2
3
var menuItem = new MenuCommand(this.MenuItemCallback, menuCommandID);
menuItem.Checked = GeneralSettings.Default.EnableComment;
commandService.AddCommand(menuItem);

默认情况下插件只有在首次被调用时才会加载。为了让其可以在 Vs 启动时就随之初始化,我们在的 Package.cs 文件中添加命令完成功能

1
2
[ProvideAutoLoad(VSConstants.UICONTEXT.NoSolution_string)]
[ProvideAutoLoad(VSConstants.UICONTEXT.SolutionExists_string)]

这样我们就获得了一个默认选中的自定义命令,可以控制插件的启用与否。

获取选中区域

我们的目的是为了实现一个可以对选中区域添加注释的功能,因此这里我们需要实现一个可以获取选中区域并且能够接受输入的命令。

同样的,我们再新建一个自定义命令在右键菜单中,右键菜单的 ID 为

1
<Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_CODEWIN" />

效果如图

10

照例修改这个 Custom Command 的回调命令。首先需要获取被选中的字符串。可以使用接口 IVsTextView 或者其他一些接口获取之。这里使用 IVsTextView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private TextViewSelection GetSelection(IServiceProvider serviceProvider)
{
var service = serviceProvider.GetService(typeof(SVsTextManager));
var textManager = service as IVsTextManager2;
IVsTextView view;
int result = textManager.GetActiveView2(1, null, (uint)_VIEWFRAMETYPE.vftCodeWindow, out view);
view.GetSelection(out startLine, out startColumn, out endLine, out endColumn);//end could be before beginning
ok = view.GetNearestPosition(startLine, startColumn, out position1, out piVirtualSpaces);
ok = view.GetNearestPosition(endLine, endColumn, out position2, out piVirtualSpaces);
var startPosition = Math.Min(position1, position2);
var endPosition = Math.Max(position1, position2);
view.GetSelectedText(out m_selectedText);
TextViewSelection selection = new TextViewSelection(startPosition, endPosition, m_selectedText);
return selection;
}

同时我们也需要获取当前选取的文本处于哪个文件中才能够针对性的存储注释信息,这里采用的方式是在源码文件名后加上后缀的方式将注释存储在源码文件同目录下。如果选中的区域中已经有注释了,则将这些注释显示出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private string GetActiveFilePath(IServiceProvider serviceProvider)
{
EnvDTE80.DTE2 applicationObject = serviceProvider.GetService(typeof(DTE)) as EnvDTE80.DTE2;
return applicationObject.ActiveDocument.FullName;
}
private string GetSelectedDocumentation(IServiceProvider serviceProvider, TextViewSelection selection)
{
var startPosition = selection.StartPosition;
var endPosition = selection.EndPosition;
var documentationText = "";
// 如果选中已经存在注释的代码段,则同时显示其注释的内容
var filepath = GetActiveFilePath(serviceProvider) + ".doc";
if (GlobalFileDocumentation.g_documentationes.ContainsKey(filepath))
{
var documentation = GlobalFileDocumentation.g_documentationes[filepath];
foreach (var fragment in documentation.Fragments)
{
int startPos = fragment.Selection.StartPosition;
int endPos = fragment.Selection.EndPosition;
if ((startPos <= startPosition && startPosition <= endPos) || (startPos <= endPosition && endPosition <= endPos))
{
documentationText += ";";
documentationText += fragment.Documentation;
}
else if ((startPosition <= startPos && endPos <= endPosition))
{
documentationText += ";";
documentationText += fragment.Documentation;
}
}
}
return documentationText;
}

添加注释窗口

编辑注释时需要弹出一个编辑框以供编辑。弹窗功能可以参考 DialogWindow 完成。
首先需要添加引用

  • PresentationCore
  • PresentationFramework
  • WindowsBase
  • System.Xaml

接着通过 Xmal 库提供的功能对 DialogWindow 进行定制。新建类 MyDialog 继承 DialogWindow

1
2
3
4
5
6
7
8
class SmartCommentWindow: DialogWindow
{
public SmartCommentWindow()
{
this.HasMaximizeButton = true;
this.HasMinimizeButton = true;
}
}

新建一个 UserControl 组件, xaml

10

添加完成后会生成两个文件 SmartCommentDlg.xamlSmartCommentDlg.xaml.cs

其中 xmal 文件用于定义这个 UserControl 的界面等,cs 文件用于定义这个 UserControl 的各种操作。

手动将这个 UserControl 指向为我们自定义的 Dialog 类,并且为之添加文本框和按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<local:SmartCommentWindow x:Class="SmartComment.CustomDialog.SmartCommentDlg"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SmartComment.CustomDialog"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Border Margin="5" >
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Add documentation for: " Margin="5"/>
<TextBox Grid.Row="1" x:Name="SelectionTextBox" Margin="5" IsReadOnly="True" MaxHeight="100"
ScrollViewer.VerticalScrollBarVisibility="Auto"
/>
<TextBlock Grid.Row="2" Margin="5">Documentation:</TextBlock>
<TextBox Grid.Row="3" x:Name="DocumentationTextBox"
AcceptsReturn="True" TextWrapping="Wrap" HorizontalAlignment="Stretch" Margin="5"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Grid.Row="4">
<Button Margin="5" Padding="5" Click="OnSave">Save</Button>
<Button Margin="5" Padding="5" Click="OnCancel">Cancel</Button>
</StackPanel>
</Grid>
</Border>
</local:SmartCommentWindow>

有时候这样做会失败~目前笔者还没有找到解决的方案。笔者在这里的做法是直接将 UserControl 类替换为 Window 类

1
<Window x:Class="SmartComment.CustomDialog.SmartCommentDlg"

为 SmartCommentDlg 类添加相应函数, 在 OnSave 操作中进行文件操作,存储当时选取的代码区域以及为之添加的注释。数据的存储形式可以依照个人的喜好自行设定,代码中有部代码将在下一部分中进行介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void OnSave(object sender, RoutedEventArgs e)
{
string filepath = this._codyDocsFilename;
var newDocFragment = new DocumentationFragment()
{
Documentation = this.DocumentationTextBox.Text,
Selection = this._selectionText
};
try
{
DocumentationFileHandler.SaveDocumentationFragment(newDocFragment, filepath);
MessageBox.Show("Documentation added successfully.");
EventAggregator.SendMessage<DocumentationAddedEvent>(
new DocumentationAddedEvent() { Filepath = filepath, DocumentationFragment = newDocFragment }
);
this.Close();
}
catch (Exception ex)
{
MessageBox.Show("Documentation add failed. Exception: " + ex.ToString());
}
}

笔者这里用 JSON 来保存注释,其中到的一个库为 JSON.Net,这个库并没有默认安装,需要自行安装。

11

12

将上一步的操作和这一步合并起来,最后修改响应函数完成功能。

1
2
3
4
5
6
private void MenuItemCallback(object sender, EventArgs e)
{
TextViewSelection selection = GetSelection(ServiceProvider);
string activeDocumentPath = GetActiveDocumentFilePath(ServiceProvider);
ShowAddDocumentationWindow(activeDocumentPath, selection);
}

代码高亮

下一步,我们尝试高亮那些拥有注释的代码,这里使用 TextMarkerTag 来实现

实现代码高亮需要使用两个关键的技术 MEFTagger

MEF 是一种轻便的框架,可以方便的让用户为 VS 添加功能。通过 Export 导出用户自定义的接口(或者说注册),通过 Import 使用接口

ITagger 是一种可以修饰编辑器的接口,可以通过它对显示在窗口中的代码进行修饰,如颜色调整,背景调整等。

实现 ITagger 接口要求必须实现两个方法 GetTagsTagsChangedGetTags 返回一系列 tag,TagsChanged 在 tag 发生更改时调用。

首先添加 Reference

  • System.ComponentModel.Composition
  • Microsoft.VisualStudio.Text.UI
  • Microsoft.VisualStudio.Text.UI.Wpf

接着为工程添加 Assert 信息,用于初始化。在 source.extension.vsixmanifest 中添加

实现代码高亮这里直接使用接口 TextMarkertag。这个接口以工厂模式创建,MEF 要求实现一个工厂类提供给框架,因此首先实现工厂类 IViewTaggerProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[Export]
[Export(typeof(IViewTaggerProvider))]
[ContentType("code")]
[TagType(typeof(CommentCodeHighlighterTag))]
class CommentCodeHighlighterTaggerProvider : IViewTaggerProvider
{
private IEventAggregator _eventAggregator;
[ImportingConstructor]
public CommentCodeHighlighterTaggerProvider(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
}
[Import]
internal ITextStructureNavigatorSelectorService TextStructureNavigatorSelector { get; set; }
public ITagger<T> CreateTagger<T>(ITextView textView, ITextBuffer buffer) where T : ITag
{
// Only provide highlighting on the top-level buffer
if (textView.TextBuffer != buffer)
return null;
return new CommentCodeHighlighterTagger(textView, buffer, _eventAggregator) as ITagger<T>;
}
}

这样,每次打开一个代码文件时 vs 都会调用这个工厂类的 CreateTagger 方法。

其中 IEventAggregator 是一个事件的整合器,用于分发事件。比如每次添加注释之后都需要重绘一次页面以显示新的高亮内容。这里直接抄的代码~~ EventAggregator.Net

新建一个 Tag 类继承 TextMarkerTag,这里就是高亮的 Tag 主体,可以修改之,以显示不同的高亮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CommentCodeHighlighterTag : TextMarkerTag
{
public CommentCodeHighlighterTag() : base("MarkerFormatDefinition/CommentCodeFormatDefinition") { }
}
[Export(typeof(EditorFormatDefinition))]
[Name("MarkerFormatDefinition/CommentCodeFormatDefinition")]
[UserVisible(true)]
internal class CommentCodeFormatDefinition :MarkerFormatDefinition
{
public CommentCodeFormatDefinition()
{
var orange = Brushes.Orange.Clone();
orange.Opacity = 0.25;
this.Fill = orange;
this.Border = new Pen(Brushes.Gray, 1.0);
this.DisplayName = "Highlight Word";
this.ZOrder = 5;
}
}

接着新建一个 Tagger 类继承 ITagger。构造函数接受两个参数 ITextViewITextBufferITextView 表示整个文档的一个 view,ITextBuffer 表示这个 view 中的 text。

这个类需要实现两个函数: GetTags用于返回给定的 range 中的所有 tag;TagsChanged:通知 Vs 当前 range 中的 tag 应该被更新了。同时这里还设定了三个事件监听函数器 _commentAddedListener_documentSavedListener_commentEnableListener,分别用于监听 添加注释事件,文件保存事件,插件启用\禁用事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class CommentCodeHighlighterTagger : ITagger<CommentCodeHighlighterTag>
{
private ITextView _textView;
private ITextBuffer _buffer;
private IEventAggregator _eventAggregator;
private readonly string _codyDocsFilename;
private readonly string _filename;
Dictionary<ITrackingSpan, string> _trackingSpans;
private readonly DelegateListener<CommentAddedEvent> _commentAddedListener;
private DelegateListener<DocumentSavedEvent> _documentSavedListener;
private DelegateListener<CommentEnableEvent> _commentEnableListener;
public CommentCodeHighlighterTagger(ITextView textView, ITextBuffer buffer, IEventAggregator eventAggregator)
{
_textView = textView;
_buffer = buffer;
_eventAggregator = eventAggregator;
_filename = GetFileName(buffer);
_codyDocsFilename = _filename + ".doc";
_commentAddedListener = new DelegateListener<CommentAddedEvent>(OnCommentAdded);
_eventAggregator.AddListener<CommentAddedEvent>(_commentAddedListener);
_documentSavedListener = new DelegateListener<DocumentSavedEvent>(OnDocumentationSaved);
_eventAggregator.AddListener<DocumentSavedEvent>(_documentSavedListener);
_commentEnableListener = new DelegateListener<CommentEnableEvent>(OnCommentEnable);
_eventAggregator.AddListener<CommentEnableEvent>(_commentEnableListener);
if (!GlobalDocumentationFileHandler.g_documentationes.ContainsKey(_codyDocsFilename))
{
GlobalDocumentationFileHandler.InitializeDocument(_codyDocsFilename);
}
CreateTrackingSpans();
}
private void CreateTrackingSpans()
{
_trackingSpans = new Dictionary<ITrackingSpan, string>();
var documentation = GlobalDocumentationFileHandler.g_documentationes[_codyDocsFilename];
var currentSnapshot = _buffer.CurrentSnapshot;
foreach (var fragment in documentation.Fragments)
{
Span span = GetSpanFromDocumentionFragment(fragment);
var trackingSpan = currentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
_trackingSpans.Add(trackingSpan, fragment.Documentation);
}
}
private static Span GetSpanFromDocumentionFragment(DocumentationFragment fragment)
{
int startPos = fragment.Selection.StartPosition;
int length = fragment.Selection.EndPosition - fragment.Selection.StartPosition;
var span = new Span(startPos, length);
return span;
}
private void RemoveEmptyTrackingSpans()
{
var currentSnapshot = _buffer.CurrentSnapshot;
var keysToRemove = _trackingSpans.Keys.Where(ts => ts.GetSpan(currentSnapshot).Length == 0).ToList();
foreach (var key in keysToRemove)
{
_trackingSpans.Remove(key);
}
}
private FileDocumentation CreateFileDocumentationFromTrackingSpans()
{
var currentSnapshot = _buffer.CurrentSnapshot;
List<DocumentationFragment> fragments = _trackingSpans
.Select(ts => new DocumentationFragment()
{
Selection = new TextViewSelection()
{
StartPosition = ts.Key.GetStartPoint(currentSnapshot),
EndPosition = ts.Key.GetEndPoint(currentSnapshot),
Text = ts.Key.GetText(currentSnapshot)
},
Documentation = ts.Value,
}).ToList();
var fileDocumentation = new FileDocumentation() { Fragments = fragments };
return fileDocumentation;
}
private void OnDocumentationSaved(DocumentSavedEvent documentSavedEvent)
{
if (GeneralSettings.Default.EnableComment)
{
if (documentSavedEvent.DocumentFullName == _filename)
{
RemoveEmptyTrackingSpans();
//FileDocumentation fileDocumentation = CreateFileDocumentationFromTrackingSpans();
var documentation = GlobalDocumentationFileHandler.g_documentationes[_codyDocsFilename];
documentation = CreateFileDocumentationFromTrackingSpans();
DocumentationFileSerializer.Serialize(_codyDocsFilename, documentation);
}
}
}
private void OnCommentAdded(CommentAddedEvent e)
{
string filepath = e.Filepath;
if (filepath == _codyDocsFilename)
{
{
_trackingSpans.Clear();
var documentation = GlobalDocumentationFileHandler.g_documentationes[_codyDocsFilename];
foreach (var fragment in documentation.Fragments)
{
var span = GetSpanFromDocumentionFragment(fragment);
var trackingSpan = _buffer.CurrentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
_trackingSpans.Add(trackingSpan, fragment.Documentation);
}
TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(
new SnapshotSpan(_buffer.CurrentSnapshot, new Span(0, _buffer.CurrentSnapshot.Length - 1))));
}
}
}
private void OnCommentEnable(CommentEnableEvent e)
{
if (_buffer.CurrentSnapshot.Length > 0)
{
if (e.CommentEnabled)
{
_trackingSpans.Clear();
var documentation = GlobalDocumentationFileHandler.g_documentationes[_codyDocsFilename];
foreach (var fragment in documentation.Fragments)
{
var span = GetSpanFromDocumentionFragment(fragment);
var trackingSpan = _buffer.CurrentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
_trackingSpans.Add(trackingSpan, fragment.Documentation);
}
TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(
new SnapshotSpan(_buffer.CurrentSnapshot, new Span(0, _buffer.CurrentSnapshot.Length - 1))));
}
else
{
_trackingSpans.Clear();
TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(
new SnapshotSpan(_buffer.CurrentSnapshot, new Span(0, _buffer.CurrentSnapshot.Length - 1))));
}
}
}
private string GetFileName(ITextBuffer buffer)
{
ITextDocument document;
buffer.Properties.TryGetProperty(
typeof(ITextDocument), out document);
return document == null ? null : document.FilePath;
}
public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
public IEnumerable<ITagSpan<CommentCodeHighlighterTag>> GetTags(NormalizedSnapshotSpanCollection spans)
{
List<ITagSpan<CommentCodeHighlighterTag>> tags = new List<ITagSpan<CommentCodeHighlighterTag>>();
if (GeneralSettings.Default.EnableComment)
{
var currentSnapshot = _buffer.CurrentSnapshot;
foreach (var trackingSpan in _trackingSpans.Keys)
{
var spanInCurrentSnapshot = trackingSpan.GetSpan(currentSnapshot);
if (spans.Any(sp => spanInCurrentSnapshot.IntersectsWith(sp)))
{
var snapshotSpan = new SnapshotSpan(currentSnapshot, spanInCurrentSnapshot);
tags.Add(new TagSpan<CommentCodeHighlighterTag>(snapshotSpan, new CommentCodeHighlighterTag()));
}
}
}
return tags;
}
}

这里需要说明是 文件保存事件,这个事件是为了在源代码被手动修改时可以同步更新注释所在的位置。

为了能够正确的触发这个事件,需要在全局对保存操作进行监控。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static class DocumentEventManager
{
private static EnvDTE.Events _events;
private static DocumentEvents _documentEvents;
private static Lazy<IEventAggregator> EventAggregator =
new Lazy<IEventAggregator>(() => MefServices.ComponentModel.GetService<IEventAggregator>());
public static void Initialize(IServiceProvider serviceProvider)
{
EnvDTE80.DTE2 applicationObject = serviceProvider.GetService(typeof(SDTE)) as EnvDTE80.DTE2;
//Need to keep strong reference to _events and _documentEvents
//otherwise they will be garbage collected
_events = applicationObject.Events;
_documentEvents = _events.DocumentEvents;
_documentEvents.DocumentSaved += OnDocumentSaved;
}
private static void OnDocumentSaved(Document document)
{
EventAggregator.Value.SendMessage<DocumentSavedEvent>(
new DocumentSavedEvent(document.FullName));
}
}

最后我们在前面添加的对话框 OnSave 函数中添加消息分发函数,发送 插件添加消息,通知插件需要更新 tag。

1
2
3
EventAggregator.SendMessage<CommentAddedEvent>(
new CommentAddedEvent() { Filepath = filepath, DocumentationFragment = newDocFragment }
);

显示注释信息

添加注释之后我们想要在阅读代码时动态显示这些注释的信息,这里使用到 QuickInfo

QuickInfo 的需要用到接口 IQuickInfoSource。和高亮显示代码不同,QuickInfo 的实现还要一个对应的 Controller 来配合,由这个 Controller 来决定什么时候显示 QuickInfo。(其实这里挺复杂的,但是之前看到的一篇文章找不到了~等以后想起来再做补充~)

首先添加引用 Microsoft.VisualStudio.Language.Intellisense

接着新建四个类:CommentQuickInfoProviderCommentQuickInfoControllerProviderCommentQuickInfoCommentQuickInfoController

目的是为了在鼠标移动到有注释的代码区域时可以显示注释

CommentQuickInfoProvider 实现 IQuickInfoSourceProvider 接口,接口要求必须实现方法 TryCreateQuickInfoSource,方法返回一个 QuickInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Export(typeof(IQuickInfoSourceProvider))]
[Name("ToolTip QuickInfo Source")]
[ContentType("code")]
internal class CommentQuickInfoProvider : IQuickInfoSourceProvider
{
private IEventAggregator _eventAggregator;
[Import]
internal ITextBufferFactoryService TextBufferFactoryService { get; set; }
[Import]
internal ITextStructureNavigatorSelectorService NavigatorService { get; set; }
[ImportingConstructor]
public CommentQuickInfoProvider(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
}
public IQuickInfoSource TryCreateQuickInfoSource(ITextBuffer textBuffer)
{
return new CommentQuickInfo(this, textBuffer, _eventAggregator);
}
}

CommentQuickInfoControllerProvider 实现 IIntellisenseControllerProvider 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
[Export(typeof(IIntellisenseControllerProvider))]
[Name("ToolTip QuickInfo Controller")]
[ContentType("text")]
internal class CommentQuickInfoControllerProvider : IIntellisenseControllerProvider
{
[Import]
internal IQuickInfoBroker QuickInfoBroker { get; set; }
public IIntellisenseController TryCreateIntellisenseController(ITextView textView, IList<ITextBuffer> subjectBuffers)
{
return new CommentQuickInfoController(textView, subjectBuffers, this);
}
}

CommentQuickInfoController 实现 IIntellisenseController ,在这个类中为 MouseMove 事件注册回调函数,触发 QuickInfo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class CommentQuickInfoController : IIntellisenseController
{
private ITextView _textView;
private IList<ITextBuffer> _subjectBuffers;
private CommentQuickInfoControllerProvider _provider;
private IQuickInfoSession _session;
public CommentQuickInfoController(ITextView textView, IList<ITextBuffer> subjectBuffers, CommentQuickInfoControllerProvider provider)
{
_textView = textView;
_subjectBuffers = subjectBuffers;
_provider = provider;
_textView.MouseHover += this.OnTextViewMouseHover;
}
private void OnTextViewMouseHover(object sender, MouseHoverEventArgs e)
{
//find the mouse position by mapping down to the subject buffer
if (GeneralSettings.Default.EnableComment)
{
SnapshotPoint? point = _textView.BufferGraph.MapDownToFirstMatch
(new SnapshotPoint(_textView.TextSnapshot, e.Position),
PointTrackingMode.Positive,
snapshot => _subjectBuffers.Contains(snapshot.TextBuffer),
PositionAffinity.Predecessor);
if (point != null)
{
ITrackingPoint triggerPoint = point.Value.Snapshot.CreateTrackingPoint(point.Value.Position,
PointTrackingMode.Positive);
if (!_provider.QuickInfoBroker.IsQuickInfoActive(_textView))
{
_session = _provider.QuickInfoBroker.TriggerQuickInfo(_textView, triggerPoint, true);
}
}
}
}
public void Detach(ITextView textView)
{
if (_textView == textView)
{
_textView.MouseHover -= this.OnTextViewMouseHover;
_textView = null;
}
}
public void ConnectSubjectBuffer(ITextBuffer subjectBuffer)
{
}
public void DisconnectSubjectBuffer(ITextBuffer subjectBuffer)
{
}
}

CommentQuickInfo 实现 IQuickInfoSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class CommentQuickInfo : IQuickInfoSource
{
private CommentQuickInfoProvider _provider;
private ITextBuffer _subjectBuffer;
private readonly string _codyDocsFilename;
private readonly DelegateListener<CommentAddedEvent> _listener;
private IEventAggregator _eventAggregator;
public CommentQuickInfo(CommentQuickInfoProvider provider, ITextBuffer subjectBuffer, IEventAggregator eventAggregator)
{
_provider = provider;
_subjectBuffer = subjectBuffer;
_eventAggregator = eventAggregator;
var fname = GetFileName(_subjectBuffer);
_codyDocsFilename = fname + ".doc";
_listener = new DelegateListener<CommentAddedEvent>(OnCommentAdded);
_eventAggregator.AddListener<CommentAddedEvent>(_listener);
if (!GlobalDocumentationFileHandler.g_documentationes.ContainsKey(_codyDocsFilename))
{
GlobalDocumentationFileHandler.InitializeDocument(_codyDocsFilename);
}
}
public void AugmentQuickInfoSession(IQuickInfoSession session, IList<object> qiContent, out ITrackingSpan applicableToSpan)
{
// Map the trigger point down to our buffer.
SnapshotPoint? subjectTriggerPoint = session.GetTriggerPoint(_subjectBuffer.CurrentSnapshot);
if (!subjectTriggerPoint.HasValue)
{
applicableToSpan = null;
return;
}
if (GeneralSettings.Default.EnableComment)
{
ITextSnapshot currentSnapshot = subjectTriggerPoint.Value.Snapshot;
SnapshotSpan querySpan = new SnapshotSpan(subjectTriggerPoint.Value, 0);
ITextStructureNavigator navigator = _provider.NavigatorService.GetTextStructureNavigator(_subjectBuffer);
TextExtent extent = navigator.GetExtentOfWord(subjectTriggerPoint.Value);
string searchText = extent.Span.GetText();
var documentation = GlobalDocumentationFileHandler.g_documentationes[_codyDocsFilename];
foreach (var fragment in documentation.Fragments)
{
int startPos = fragment.Selection.StartPosition;
int endPos = fragment.Selection.EndPosition;
int length = endPos - startPos;
var snapshotSpan = new SnapshotSpan(currentSnapshot, new Span(startPos, length));
if (extent.Span.Start.Position >= startPos && extent.Span.End.Position <= endPos)
{
// 鼠标处于注释区域之内
applicableToSpan = currentSnapshot.CreateTrackingSpan(extent.Span.Start, searchText.Length, SpanTrackingMode.EdgeInclusive);
string comment = fragment.Documentation;
if (comment != null)
qiContent.Add(comment);
else
qiContent.Add("");
return;
}
}
}
applicableToSpan = null;
}
public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
private string GetFileName(ITextBuffer buffer)
{
ITextDocument document;
buffer.Properties.TryGetProperty(
typeof(ITextDocument), out document);
return document == null ? null : document.FilePath;
}
private bool m_isDisposed;
public void Dispose()
{
if (!m_isDisposed)
{
GC.SuppressFinalize(this);
m_isDisposed = true;
}
}
}

这样就实现了一个可以添加注释,并且动态显示的 VS 扩展

但是这里的实现其实是有问题的,首先对于 AddEvent 的事件响应不应该出现在 QuickInfo 这个类中;其次如果这个插件运行在调试状态下还会发现,QuickInfo 并不能显示出来。这些问题都需要以后去改进

更新

插件编写环境是 vs2015 因此当 vs 升级到 2017 之后,项目需要迁移之后重新编译才能够使用,迁移的方法参见[官方文档] (https://docs.microsoft.com/zh-cn/visualstudio/extensibility/how-to-migrate-extensibility-projects-to-visual-studio-2017)

这里有一个问题是,由于项目中使用了第三方的库 Newtonsoft, 因此需要手动指定其位置,否则会出现找不到的情况。步骤如下

  • Open or edit the .vsixmanifest file of your package project

  • Go to Assets section

  • Click the New button

  • Select Type: Microsoft.VisualStudio.Assembly

  • Select Source: File on filesystem

总结

第一次写 Vs 的扩展,对其中很多 API 和接口使用还存在很多值得商榷的地方,一些功能的设计也存在很多的不足。但是插件目前的功能已经可以满足我平时阅读代码的需求了,介于笔者做事情拖沓的毛病,这些问题暂且记录在案,以后等出现了新的需求再行解决。

插件的编写大量的参考了引用 【1】 博客中的内容,对其中没有详细说明的地方进行了补充。博客的作者还在持续的更新这一系列文章,待文章全部更新之后,笔者将参考之并对自己的插件进行可能的更新。

再次感谢 【1】 作者的分享。

Reference