65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 实现 C# 文件文档大纲窗口

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (61投票s)

2010年7月27日

CPOL

14分钟阅读

viewsIcon

114492

downloadIcon

1430

用于 C# 文件的 Visual Studio 工具窗口文档大纲,使用 WPF 编写

引言

我发现代码文件组织得当非常有价值。这意味着遵循一定的元素顺序(成员变量在顶部,然后是属性、构造函数等),并在适当时按字母顺序排列变量/方法。这有助于快速浏览文件并了解其内容,尤其是在文件很大或是我以前从未处理过的文件时。正如您可能想象的那样,我非常喜欢区域(regions)。它们非常适合这种组织方式,并允许以折叠的“楼层平面图”视图查看文件。

然而,许多开发人员并不这么认为。许多人非常不喜欢区域,因为它们可以隐藏代码,或者认为这种组织方式毫无价值。虽然我认为这些问题可以通过坚持一些最佳实践(例如,不要嵌套区域)来轻松缓解,但事实是,许多开发人员和团队不想要它们。此外,正确且一致地维护区域可能需要大量工作。ReSharper 等工具可以提供帮助,但对于大型、数千行的文件来说,仍然可能非常耗时。

出于这些原因,我开始认为 IDE 应该直接为我完成这项工作。就像导航栏一样,我应该能够看到文件中所有元素的列表并导航到它们,但这些元素被组织成逻辑的、可折叠的部分。这样的工具将允许我放弃代码中的区域,而不是强迫其他开发人员使用它们,但仍然能获得它们的好处。

背景

此工具探索了许多概念。首先,它涉及创建一个 Visual Studio 插件和工具窗口来托管自定义控件,以及在一个 WinForms 控件中托管一个 WPF 控件。其次,它使用 Visual Studio 自动化模型和代码模型以编程方式探索代码。最后,主控件是用 WPF 编写的,所以我们可以从中学习一些基本知识。

使用代码

要运行此插件,只需生成项目,然后将 DLL 和插件文件(项目根目录下的 DocOutlineCSharp.AddIn)放入您的 Visual Studio 插件目录(*“%UserProfile%\My Documents\Visual Studio 20xx\AddIns”*)。如果该文件夹不存在,请创建它。然后,当您打开 Visual Studio 时,转到“工具”->“插件管理器”,选中插件,如果需要,启用“启动时启用”,然后单击“确定”。如果窗口没有自动弹出,或者您关闭了它并想稍后重新打开它,您可以在“视图”->“其他窗口”->“文档大纲(C#)”下找到该窗口的命令。此功能已在 VS2010 和 2008 中测试通过。由于使用了 WPF,它可能无法在 2005 中运行。

如果您希望加载解决方案并通过按 F5 即可看到其运行效果,只需修改您放在插件目录中的插件文件,使其指向解决方案输出文件夹中的 DLL,而不是当前设置的本地文件夹。

好的,现在开始讲代码!

Visual Studio 插件的核心是其 Connect 类。当您创建一个新插件时,它会为您生成这个类,但为了更好地理解它并“让它成为我的”,我对其进行了重新格式化/重命名,以更好地反映我自己的编程风格。如果您以前没有使用过 Connect 类,这可能有助于您理解它的作用,也可能无助于您理解。

Connect 类

对于我们的目的,标准的 Connect 类中有几个值得关注的地方。我们必须挂钩 IDE 的 WindowActivatedWindowClosing 事件,以便我们知道何时开始大纲化代码。我们在 OnConnection 方法中这样做,并在 OnDisconnection 方法中取消挂钩。这些事件处理程序的作用稍后将进行描述。

// get environment window events and hook into WindowActivated and WindowClosing
winEvents = (WindowEvents)app.Events.get_WindowEvents(null);
winEvents.WindowActivated += new 
  _dispWindowEvents_WindowActivatedEventHandler(winEvents_WindowActivated);
winEvents.WindowClosing += new 
  _dispWindowEvents_WindowClosingEventHandler(winEvents_WindowClosing);

现在我们必须创建工具窗口,在其中指定窗口应托管的用户控件。最初,该控件是用 WPF 编写的。这导致了两个问题。首先,因为它继承自 WPF 的 UserControl,而 UserControl 不是 COM 可见的,所以 Windows2.CreateToolWindow2 通常用我们用户控件实例的引用填充的 ref object ControlObject 总是返回 null,这需要作为一种变通方法,从控件内部提供对我们用户控件实例的引用。其次,它奇怪地导致我们的工具窗口在与其他窗口对接时,标题文本消失了。将基础用户控件设为 WinForms,并在其中使用 ElementHost 托管一个 WPF 控件可以解决这两个问题。现在标题可以正常工作,并且 ref object ControlObject 也会被正确填充。

最后,在 OnConnection 中,为用户控件提供应用程序环境的引用,并执行我们的第一个代码大纲操作(稍后详述)。

事件处理程序

在我们之前挂钩的 WindowActivated 事件处理程序中,我们希望重新大纲化代码,因为我们切换到了一个新的窗口,但前提是

  1. 我们有一个有效的 Outline Window 控件,并且
  2. 我们切换到的窗口是一个有效的文档窗口而不是工具窗口,并且
  3. 大纲窗口当前没有跟踪任何文档,**或** 它正在跟踪一个文档,但它跟踪的文档不是刚刚切换到的文档。
void winEvents_WindowActivated(Window GotFocus, Window LostFocus)
{
    if (OutlineWindow != null && GotFocus.Document != null && 
       (OutlineWindow.CurrentDoc == null || 
       (OutlineWindow.CurrentDoc != null && 
        OutlineWindow.CurrentDoc.Name != GotFocus.Caption)))

        OutlineWindow.OutlineCode();
}

上述条件确保我们在切换文档时重新大纲化代码,并且当我们刚刚切换到工具窗口然后又回到我们正在处理的代码文件时,不会不必要地再次执行此操作。

WindowClosing 事件处理程序中,我们只需清除大纲树,然后让它从下一个激活窗口的 WindowActivated 事件处理程序重新填充。

void winEvents_WindowClosing(Window Window)
{
    if (OutlineWindow != null)
        OutlineWindow.ClearElements();
}

Connect 类就到此为止;现在我们来谈谈执行代码大纲操作的自定义控件。

自定义 WPF 用户控件 - DocOutline

Visual Studio 工具窗口将托管的控件是一个名为 DocOutlineHost 的 WinForms 控件。在该控件内部,我们有一个 ElementHost,它托管将执行工作的 WPF 控件。它使用 WPF 编写是为了利用一些额外的 UI 灵活性,也是因为我想更多地了解它。让我们从它的构造函数开始,它非常基础。

public DocOutline()
{
    InitializeComponent();
    Application.ResourceAssembly = Assembly.GetExecutingAssembly();

    refreshButton.Click += new RoutedEventHandler(refreshButton_Click);
    expandButton.Click += new RoutedEventHandler(expandButton_Click);
        collapseButton.Click += new RoutedEventHandler(collapseButton_Click);
}

Connect 类中前面提到的 CreateToolWindow2 方法引用托管的 WinForms 控件时,就会调用此方法。我们在这里设置 ResourceAssembly,以便稍后可以使用短的、相对的资源路径。它似乎不会默认使用自己的程序集,可能是因为我们在 Visual Studio 插件中。我们还挂钩了我们拥有的刷新、展开和折叠按钮。

这个控件创建后的主要入口点是 OutlineCode 方法,该方法最初由 OnConnection 方法调用,以及每次我们激活新文档窗口时调用。这是该工具的核心逻辑。

OutlineCode()

首先,我们通过 Visual Studio 代码模型获取文件中的所有代码元素。

elements = DTE.ActiveDocument.ProjectItem.FileCodeModel.CodeElements;

对于每个元素,我们“展开”它,查看它有什么,在必要时将项添加到大纲树中,并进一步展开其中的子项(详细信息稍后)。

for (int i = 1; i <= elements.Count; i++)
    ExpandElement(elements.Item(i), null);

一旦所有节点都已添加到树中,我们就需要对它们进行排序。“Kind”节点(我们的组,如 Fields、Properties、Public Methods 等)我们希望根据我们定义的预定顺序进行排序。我们使用自定义比较器来完成此操作,在此不详细说明。每个组节点内的元素节点,我们只想按字母顺序排序。因为一个节点可以是分组(包含代码元素),也可以是类(包含分组元素),并且类可以无限嵌套,所以我们必须递归地执行排序,根据父节点类型按字母顺序或使用我们的自定义比较器进行排序。

private void SortNodes(ItemCollection items, IComparer<TreeViewItem> comparer = null)
{
    List<TreeViewItem> itemList = new List<TreeViewItem>();

    foreach (TreeViewItem item in items)
        itemList.Add(item);

    items.Clear();

    if (comparer != null)
    {
        itemList.Sort(comparer);
    }
    else
    {
        try
        {
            itemList = itemList.OrderBy(i => 
               GetTreeViewItemNameBlock(i).Text.ToString()).ToList();
        }
        catch { }
    }

    foreach (TreeViewItem item in itemList)
        items.Add(item);

    foreach (TreeViewItem item in items)
    {
        if (item.Items != null && item.Items.Count > 0)
        {
            SortNodes(item.Items, item.Name == "GroupNode" ? null : new KindComparer());
        }
    }
}

最后,我们展开所有节点,以便用户可以看到完整的大纲,并将此文档设置为我们的“当前文档”以进行跟踪。

ExpandCollapseChildren(elementTree.Items, true);

CurrentDoc = DTE.ActiveDocument;

现在我们将看到如何检查每个代码元素并决定如何处理它。

ExpandElement()

对于我们遇到的每个代码元素,我们可能希望将其添加到大纲树中,也可能不希望,但我们肯定希望进一步展开它以查看它下方还有什么。后一部分很容易,只需递归调用即可。CodeElement.Kind 将告诉我们正在处理的代码元素的类型。如果代码元素是命名空间,我们不希望将其添加到树中,所以只需获取其成员(类、枚举等)并展开每个成员即可。

if (element.Kind == vsCMElement.vsCMElementNamespace)
{
    CodeElements members = ((CodeNamespace)element).Members;

    for (int i = 1; i <= members.Count; i++)
        ExpandElement(members.Item(i), parent);
}

如果它是一个类,我们确实希望将其添加到树中,我们也希望展开其每个成员。我们获取类的名称,为其添加一个 TreeViewItem,并将其添加到我们遇到的元素列表中,以及它在文件中的位置,以便稍后进行导航。

CodeClass cls = (CodeClass)element;

string fullName = cls.FullName;
string name = cls.FullName.Split('.').Last();

TreeViewItem classItem = CreateTreeViewItem(fullName, name, string.Empty, 
             string.Empty, false, "Classes", new vsCMAccess());

items.Add(classItem);
treeElements.Add(new EncounteredCodeElement() { 
       FullName = fullName, Name = name, Location = element.StartPoint });

CreateTreeViewItem 方法的详细信息以及我们稍后如何导航到这些项即将到来。然后我们使用与之前类似的​​代码展开每个子元素。

其他元素类型是“其他所有内容”:字段、属性、方法等。我们有一个巨大的 switch 语句,用于根据其类型(kind)强制转换元素,然后我们执行一些操作。我们希望获取其名称、类型、是否为静态(IsShared == true),是否为常量(在字段情况下),以及它可能拥有的任何访问修饰符。基于此,我们创建一个“kind”字符串,这是我们将把此元素放入的“kind”(组)的名称。下面的代码是“variable”类型的(kind 是一个 string,表示我们希望此元素所在的组的名称)。

CodeVariable var = (CodeVariable)element;
name = var.Name;
fullName = var.FullName;
fullType = var.Type.AsString;

if (var.IsShared)
    kind += "Static ";
if (var.IsConstant)
    kind += "Constants";
else
    kind += "Fields";

access = var.Access;

对于函数元素有一些额外的逻辑。我们希望 TreeViewItem 上的文本显示整个方法签名,而不是仅仅显示方法名称,因此我们必须通过迭代 CodeFunction.Parameters 并根据需要添加括号和逗号来自己构建此字符串。我们还通过一个映射 Dictionary 将访问修饰符添加到“kind”字符串中。如果方法名称与我们所在的类名称相同,我们将 kind 设置为“Constructors”。如果以下条件成立,我们将 kind 设置为“Event Handlers”:方法返回类型为 void,并且有两个参数,第一个参数类型为 System.Object,第二个参数派生自 System.EventArgs。所有这些代码都相当直观,可以在附加的源代码中看到。

添加到 TreeView

此时,在收集了我们代码元素的所有相关信息并构建了我们的“kind”/group 字符串后,如果我们有一个常规代码项(不是类或命名空间,我们可以通过检查“kind”字符串是否为空来判断),我们就希望将其添加到我们的 TreeView 中。首先,我们必须决定将新节点添加到哪个节点集。如果我们向 ExpandElement 方法提供了父元素,我们就使用该父元素的子节点。否则,我们使用树的根节点。

ItemCollection items = parent != null ? parent.Items : elementTree.Items;

现在我们找到要添加我们元素的“kind”项,如果找不到,就创建它。在这个过程中,我们使用一个方法 GetTreeViewItemNameBlock 来获取包含每个我们传递的节点的可见名称的 TextBlock 元素。您可能认为使用 TreeViewItem 本身的 Name 属性会更简单,但该属性不允许名称中出现诸如 . 和 <> 之类的字符,而这些字符对于唯一标识方法签名等是必需的。此外,有时我们需要用户可见名称和完全限定名称(存储在 TextBlockToolTip 中,在 CreateTreeViewItem 方法中显示),例如以便稍后在单击时找到该元素,因此无论如何此方法都会很有用。

我们希望将元素的类型以粗体添加到我们的 TreeViewItem 中。如果它是一个简单类型,我们只需将其非限定化以提高可读性,然后就完成了。如果它是一个“类型化”类型(带有 <>),我们希望取消限定尖括号内的每种类型,这需要一些逻辑,您可以在源代码中找到。

最后,我们可以创建我们的 TreeViewItem 并将其添加到我们组的子项中,并将元素添加到我们遇到的元素和位置列表中。

最后要涵盖的是我们创建 TreeViewItem 的方法,以及从树导航到项的代码。

CreateTreeViewItem()

在此方法中,我们提供了一系列内容,如限定和非限定名称和类型、kind/group 以及任何访问修饰符,并创建一个 TreeViewItem。这基本上是代码中的简单 WPF UI 操作。首先,我们创建一个新的 TreeViewItem 和一个 StackPanel,将其 Orientation 设置为 Horizontal

TreeViewItem item = new TreeViewItem();
StackPanel stack = new StackPanel();
stack.Orientation = Orientation.Horizontal;
stack.Height = 16;

接下来,我们创建一个用于图标的网格。我们使用一个简单的 Dictionary 映射,根据“kind”/group 和任何访问修饰符,从我们的程序集中获取图标资源,并使用一个 BitmapImage 和一个 Uri 将其设置为 ImageSource。好处是我们还可以获取多个图像,例如,用于字段的蓝色立方体和用于 private 的挂锁,并将它们都添加到网格中,它们将正确地相互叠加。

Image kindImage = new Image();
kindImage.Name = "kindImage";
kindImage.Source = new BitmapImage(new Uri(@"/Resources/" + 
                   kindImageMapping[kind] + ".png", UriKind.Relative));
grid.Children.Add(kindImage);

接下来,我们为名称和类型创建 TextBlock,如果名称是类,则将其斜体化,如果它是组,则将其粗体化,并将类型粗体化。我们还为每个 ToolTip 设置完全限定名称,供用户参考。最后,我们将图像网格和两个 TextBlock 添加到 StackPanel 中,并将 StackPanel 设置为 TreeViewItemHeader。我们挂钩 MouseDoubleClickSelected 事件,并返回 TreeViewItem

最后一步……导航

最后一步是从大纲窗口进行导航。与普通的 Visual Studio 导航栏一样,我们希望用户能够双击一个项并被带到源代码中的位置。不幸的是,MouseDoubleClick 事件的行为似乎很不寻常,它会在从源到最高父级的每个元素上单独触发,因此无法通过处理事件来阻止。所以,无论我们双击哪个项,我们总是会被导航到最高父级。这可以通过缓存所选的 TreeViewItem(在选择时)并仅当所选项与双击事件的源匹配时执行导航来阻止。在 Selected 事件处理程序中

void item_Selected(object sender, RoutedEventArgs e)
{
    selected = sender as TreeViewItem;
    e.Handled = true;
}

以及在我们的 MouseDoubleClick 事件处理程序中

if (selected != e.Source)
    return;

接下来,我们获取选定的 TreeViewItem 中包含名称的 TextBlock。我们使用它在我们的遇到的元素和位置列表中查找该元素。

TextBlock nameBlock = GetTreeViewItemNameBlock((TreeViewItem)sender);
EncounteredCodeElement foundElement = treeElements.Find(el => 
   el.Name == nameBlock.Text && el.FullName == nameBlock.ToolTip.ToString());

然后,只需将 DTE.ActiveDocument.Selection 移动到指定点即可。

EnvDTE.TextSelection selection = (EnvDTE.TextSelection)DTE.ActiveDocument.Selection;
selection.MoveToPoint(foundElement.Location);

还有一小段额外的逻辑。默认情况下,上面的代码会将光标移动到代码元素所在行的开头。要使其移动到元素名称的开头(与内置导航栏一样,并触发 VS 2010 的高亮显示功能),我们必须将光标移动到第一个找到的括号或行尾,然后从该点向后查找非限定名称的第一个实例。必须这样做,以确保我们不会将光标错误地放在同名的类型或参数上。

if (!selection.FindPattern("("))
    selection.EndOfLine();

string name = foundElement.FullName.Split('.').Last();

selection.FindPattern(name, (int)vsFindOptions.vsFindOptionsMatchCase | 
                            (int)vsFindOptions.vsFindOptionsBackwards);
selection.CharLeft();

大功告成!

docoutline.png

关注点

总而言之,这是一个相对直接的工具,但它包含一些怪异之处和相当多的逻辑。值得注意的奇怪之处包括需要 WinForms 主机来避免创建工具窗口时的 null ControlObject 和丢失的标题,以及 MouseDoubleClick 相对于其他常规的冒泡类型 WPF 事件的独特行为。此外,在 XP 上获取 Aero 外观和感觉需要显式地将 Presentation.Aero DLL 作为合并资源字典包含进来。这肯定有相当多的学习内容,但其中大部分与 Visual Studio 插件的怪异之处和 WPF 本身(资源 UriTreeView、事件等)有关。Visual Studio 代码模型本身相当简单。

我发现此工具对于导航和浏览代码文件非常有帮助,希望您也会发现它很有用!这是我提交到 The Code Project 的第一篇文章,因此非常感谢您的反馈。谢谢!

历史

  • 2010 年 7 月 27 日 - 初次提交
  • 2010 年 7 月 28 日 - 添加了运行插件/解决方案的说明
  • 2010 年 8 月 17 日 - 改进了事件处理程序检测,修复了导航到重载方法的问题,清理了代码以进行类型非限定化,并添加了正确地进行类型化参数(带 <>)的非限定化。
  • 9/8/10
    • **修复**:类未正确嵌套,导航到不同类中同名元素的问题(通过存储和查找完全限定类型),<T> 未在方法名中显示,以及工具窗口标题在与其他窗口对接时消失的问题(通过将 WPF 控件托管在 WinForms 控件中)。
    • **增强**:切换到 Aero 外观,无论操作系统如何,添加了带有展开/折叠全部按钮的工具栏,为 struct 添加了类别,并修复了 **Visual Studio 2008 支持**。
  • 2010 年 10 月 12 日 - 插件已转换为 VSIX 扩展并提交到 Visual Studio Gallery(有关详细信息,请参阅文章开头)。
© . All rights reserved.