使用 Visual Studio 編輯器擴充性

Visual Studio 編輯器支援新增至其功能的擴充功能。 範例包括以現有語言插入和修改程式碼的擴充功能。

針對新 Visual Studio 擴充性模型的初始版本,僅支援下列功能:

  • 接聽正在開啟和關閉的文字檢視。
  • 接聽文字檢視 (編輯器) 狀態變更。
  • 讀取文件的文字和選項/插入號位置。
  • 執行文字編輯和選項/插入號變更。
  • 定義新的文件類型。
  • 使用新的文字檢視邊界擴充文字檢視。

Visual Studio 編輯器通常是指編輯任何類型的文字檔案 (即文件) 的功能。 可以開啟個別檔案進行編輯,而開啟的編輯器視窗稱為 TextView

編輯器物件模型會在編輯器概念中描述。

開始使用

您的擴充功能程式碼可以設定為執行,以回應各種進入點 (當使用者與 Visual Studio 互動時發生的情況)。 編輯器擴充性目前支援三個進入點:接聽程式、EditorExtensibility 服務物件,和命令。

當編輯器視窗中發生特定動作時,事件接聽程式會觸發,在程式碼中以 TextView 表示。 例如,當使用者在編輯器中輸入某個專案時,就會發生 TextViewChanged 事件。 當編輯器視窗開啟或關閉時,會發生 TextViewOpenedTextViewClosed 事件。

編輯器服務物件是 EditorExtensibility 類別的執行個體,該物件會公開即時編輯器功能,例如執行文字編輯。

命令是由使用者在項目上按一下來起始,您可以將其放在功能表、內容功能表,或工具列上。

新增文字檢視接聽程式

接聽程式有兩種類型:ITextViewChangedListenerITextViewOpenClosedListener。 這些接聽程式可用來觀察文字編輯器的開啟、關閉和修改。

然後,建立新的類別,實作 ExtensionPart 基類和 ITextViewChangedListenerITextViewOpenClosedListener,或兩者,並新增 VisualStudioContribution 屬性。

然後,根據 ITextViewChangedListenerITextViewOpenClosedListener 的要求,實作 TextViewExtensionConfiguration 屬性,使接聽程式在編輯 C# 檔案時套用:

public TextViewExtensionConfiguration TextViewExtensionConfiguration => new()
{
    AppliesTo = new[] { DocumentFilter.FromDocumentType("CSharp") },
};

本文稍後列出了其他程式語言的可用文件類型和檔案類型,並且還可以在需要時定義自訂檔案類型。

假設您決定實作這兩個接聽程式,則完成的類別宣告看起來應該如下所示:

  [VisualStudioContribution]                
  public sealed class TextViewOperationListener :
      ExtensionPart, // This is the extension part base class containing infrastructure necessary to use VS services.
      ITextViewOpenClosedListener, // Indicates this part listens for text view lifetime events.
      ITextViewChangedListener // Indicates this part listens to text view changes.
  {
      public TextViewExtensionConfiguration TextViewExtensionConfiguration => new()
      {
          // Indicates this part should only light up in C# files.
          AppliesTo = new[] { DocumentFilter.FromDocumentType("CSharp") },
      };
      ...

由於 ITextViewOpenClosedListenerITextViewChangedListener 宣告 TextViewExtensionConfiguration 屬性,因此組態會套用至這兩個接聽程式。

當您執行擴充功能時,應該會看到:

這些方法中的每一個都會傳遞一個 ITextViewSnapshot,其中包含使用者叫用動作時文字檢視和文字文件的狀態,以及一個 CancellationToken,當 IDE 希望取消待完成動作時,該 CancellationToken 將會具有 IsCancellationRequested == true

定義您的擴充功能何時相關

您的擴充功能通常只與某些支援的文件類型和案例相關,因此請務必清楚定義其適用性。 您可以使用 AppliesTo 組態,以數種方式清楚定義擴充功能的適用性。 您可以指定擴充功能支援的程式碼語言之類的檔案類型,以及/或根據檔案名稱或路徑比對模式來進一步調整擴充功能的適用性。

使用 AppliesTo 組態指定程式設計語言

AppliesTo 組態指出擴充功能應該啟動的程式設計語言案例。 它會寫入為 AppliesTo = new[] { DocumentFilter.FromDocumentType("CSharp") },其中文件類型是 Visual Studio 內建語言的已知名稱,或 Visual Studio 擴充功能中定義的自訂名稱。

下列資料表顯示一些已知的文件類型:

DocumentType 描述
"CSharp" C#
"C/C++" C、C++、標頭,和 IDL
"TypeScript" TypeScript 和 JavaScript 類型語言。
"HTML" HTML
"JSON" JSON
"text" 文字檔,包括 “code” 的階層式子系,從 “text” 遞減。
"code" C、C++、C# 等等。

DocumentTypes 是階層式的。 也就是說,C# 和 C++ 都是從 "code" 遞減,因此宣告 "code" 會導致您的擴充功能啟用所有程式碼語言,C#、C、C++ 等。

定義新的文件類型

您可以定義新的文件類型,例如支援自訂程式碼語言,方法是將靜態 DocumentTypeConfiguration 屬性新增至擴充功能專案中的任何類別,並以 VisualStudioContribution 屬性標記屬性。

DocumentTypeConfiguration 可讓您定義新的文件類型、指定它繼承一個或多個其他文件類型,以及指定一個或多個用來識別檔案類型的副檔名:

using Microsoft.VisualStudio.Extensibility.Editor;

internal static class MyDocumentTypes
{
    [VisualStudioContribution]
    internal static DocumentTypeConfiguration MarkdownDocumentType => new("markdown")
    {
        FileExtensions = new[] { ".md", ".mdk", ".markdown" },
        BaseDocumentType = DocumentType.KnownValues.Text,
    };
}

文件類型定義會與舊版 Visual Studio 擴充性所提供的內容類型定義合併,可讓您將其他副檔名對應至現有的文件類型。

文件選取器

除了 DocumentFilter.FromDocumentType 之外,DocumentFilter.FromGlobPattern 還可讓您在文件檔案路徑符合 glob (萬用字元) 模式時,進一步限制擴充功能的適用性:

[VisualStudioContribution]                
public sealed class TextViewOperationListener
    : ExtensionPart, ITextViewOpenClosedListener, ITextViewChangedListener
{
    public TextViewExtensionConfiguration TextViewExtensionConfiguration => new()
    {
        AppliesTo = new[]
        {
            DocumentFilter.FromDocumentType("CSharp"),
            DocumentFilter.FromGlobPattern("**/tests/*.cs"),
        },
    };
[VisualStudioContribution]                
public sealed class TextViewOperationListener
    : ExtensionPart, ITextViewOpenClosedListener, ITextViewChangedListener
{
    public TextViewExtensionConfiguration TextViewExtensionConfiguration => new()
    {
        AppliesTo = new[]
        {
            DocumentFilter.FromDocumentType(MyDocumentTypes.MarkdownDocumentType),
            DocumentFilter.FromGlobPattern("docs/*.md", relativePath: true),
        },
    };

pattern 參數代表在文件的絕對路徑上相符的 glob 模式。

Glob 模式可以具有下列語法:

  • * 比對路徑區段中的零個或多個字元
  • ? 比對路徑區段中的一個字元
  • ** 比對任意數目的路徑區段,包括沒有
  • {} 分組條件 (例如,**​/*.{ts,js} 符合所有 TypeScript 和 JavaScript 檔案)
  • [] 在路徑區段中宣告要比對的字元範圍 (例如,example.[0-9] 要比對 example.0example.1、...)
  • [!...] 否定路徑區段中要比對的字元範圍 (例如,example.[!0-9]example.a 上比對,example.b,但不是 example.0)

反斜線 (\) 在glob 模式中無效。 建立 glob 模式時,請務必將任何反斜線轉換為斜線。

存取編輯器功能

您的編輯器擴充類別繼承自 ExtensionPartExtensionPart 類別會公開擴充性屬性。 使用這個屬性,您可以要求 EditorExtensibility 物件的執行個體。 您可以使用這個物件來存取即時編輯器功能,例如執行編輯。

EditorExtensibility editorService = this.Extensibility.Editor();

存取命令內的編輯器狀態

在每個 Command 中的 ExecuteCommandAsync() 都會傳遞包含叫用命令時 IDE 狀態快照集的 IClientContext。 您可以透過 ITextViewSnapshot 介面存取使用中文件,方法是藉由呼叫非同步方法 GetActiveTextViewAsyncEditorExtensibility 物件取得:

using ITextViewSnapshot textView = await this.Extensibility.Editor().GetActiveTextViewAsync(clientContext, cancellationToken);

擁有 ITextViewSnapshot之後,您就可以存取編輯器狀態。 ITextViewSnapshot是編輯器狀態在某個時間點不可變的檢視,因此您必須使用編輯器物件模型中的其他介面來進行編輯。

從擴充功能變更文字文件

編輯,也就是在 Visual Studio 編輯器中開啟的文字文件變更,可能是由使用者互動、Visual Studio 中的執行緒,例如語言服務和其他擴充功能引起。 您的擴充功能必須準備好處理即時發生的文件文字變更。

在主要 Visual Studio IDE 處理序之外執行的擴充功能,使用非同步設計模式與 Visual Studio IDE 處理序溝通。 這表示使用非同步方法呼叫,如 C# 中的 async 關鍵詞所指示,並以方法名稱的尾碼強化 Async。 在期望回應使用者動作之編輯器的內容中,非同步是一個顯著的優勢。 傳統的同步 API 呼叫若花費的時間超過預期,將會停止回應使用者輸入,建立直到 API 呼叫完成為止的 UI 凍結。 使用者對現代互動式應用程式的期望是文字編輯器始終保持回應,並且永遠不會阻止它們工作。 因此,非同步擴充功能對於滿足使用者期望至關重要。

若要深入了解非同步程式設計,請參閱使用 async 和 await 進行非同步程式設計

在新的 Visual Studio 擴充性模型中,擴充功能對於使用者而言是第二個類別:它無法直接修改編輯器或文字文件。 所有狀態變更都是非同步且合作的,Visual Studio IDE 會代表擴充功能執行要求的變更。 擴充功能可以在特定版本的文件或文字檢視上要求一個或多個變更,但擴充功能的變更可能會遭到拒絕,例如,如果文件的該區域已變更。

使用 EditorExtensibility 上的 EditAsync() 方法要求編輯。

如果您熟悉舊版 Visual Studio 擴充功能,ITextDocumentEditor 與從 ITextBufferITextDocument 變更方法的狀態幾乎相同,而且支援大部分相同的功能。

MutationResult result = await this.Extensibility.Editor().EditAsync(
batch =>
{
    var editor = document.AsEditable(batch);
    editor.Replace(textView.Selection.Extent, newGuidString);
},
cancellationToken);

為了避免錯位編輯,會套用來自編輯器擴充功能的編輯,如下所示:

  1. 擴充功能會要求根據文件的最新版本進行編輯。
  2. 該要求可能包含一個或多個文字編輯、插入號位置變更等等。 任何類型實作 IEditable 都可以在單 一 EditAsync() 要求中變更,包括 ITextViewSnapshotITextDocumentSnapshot。 編輯是由編輯器完成,其可透過 AsEditable() 在特定類別上要求。
  3. 編輯要求將會傳送到 Visual Studio IDE,只有當被變更的物件自發出要求的版本以來未發生變更時,編輯要求才會成功。 如果文件已變更,則變更可能會遭到拒絕,要求在較新版本上重試擴充功能。 變異運算的結果會儲存在 result
  4. 編輯以不可分割方式應用,這意味著不會中斷其他正在執行的執行緒。 最佳做法是在單一 EditAsync() 呼叫的狹窄時間範圍內執行所有應該發生的變更,以減少使用者編輯所產生的非預期行為的可能性,或編輯之間發生的語言服務動作 (例如,擴充功能編輯與 Roslyn C# 交錯移動插入號)。

非同步執行

ITextViewSnapshot.GetTextDocumentAsync 會在 Visual Studio 擴充功能中開啟文字文件的複本。 由於擴充功能會在個別處理序執行,因此所有擴充功能互動都是非同步、合作的,而且有一些注意事項:

警告

如果在舊的 ITextDocument 上呼叫 ,因為 Visual Studio 用戶端可能不再快取它,如果使用者自建立之後已進行許多變更,則 GetTextDocumentAsync 可能會失敗。 基於這個理由,如果您打算稍後儲存 ITextView 以存取其文件,而且無法容忍失敗,建議您立即呼叫 GetTextDocumentAsync 。 這樣做會將該版本的文件的文字內容擷取到您的擴充功能中,確保該版本的複本在過期之前傳送到您的擴充功能。

警告

如果使用者關閉文件,GetTextDocumentAsyncMutateAsync 可能會失敗。

並行執行

⚠️ 編輯器擴充功能有時會並行執行

初始版本有已知問題,可能會導致編輯器擴充功能程式碼並行執行。 每個非同步方法會確保以正確的順序呼叫,但第一個 await 之後的接續作業可能會交錯。 如果您的擴充功能依賴執行順序,請考慮維護傳入要求的佇列來保留順序,直到修正此問題。

如需詳細資訊,請參閱 StreamJsonRpc 預設排序和並行

使用新的邊界擴充 Visual Studio 編輯器

擴充功能可以將新的文字檢視邊界提供給 Visual Studio 編輯器。 文字檢視邊界是一個矩形的 UI 控制項,附加在文字檢視的四個端之一。

文字檢視邊界會放在邊界容器中 (請參閱 ContainerMarginPlacement.KnownValues),並在相對於其他邊界之前或之後排序 (請參閱 MarginPlacement.KnownValues)。

文字檢視邊界提供者會實作 ITextViewMarginProvider 介面、藉由實作 TextViewMarginProviderConfiguration 來設定其提供的邊界,並在啟動時,透過 CreateVisualElementAsync 提供要裝載在邊界中的 UI 控制項。

由於 VisualStudio.Extensibility 中的擴充功能可能來自 Visual Studio 的跨處理序,因此無法直接使用 WPF 做為文字檢視邊界內容的存取層。 相反地,將內容提供給文字檢視邊界需要建立 RemoteUserControl 和該控制項的對應資料範本。 雖然以下有一些簡單的範例,但建議您在建立文字檢視邊界 UI 內容時,閱讀遠端 UI 文件

/// <summary>
/// Configures the margin to be placed to the left of built-in Visual Studio line number margin.
/// </summary>
public TextViewMarginProviderConfiguration TextViewMarginProviderConfiguration => new(marginContainer: ContainerMarginPlacement.KnownValues.BottomRightCorner)
{
    Before = new[] { MarginPlacement.KnownValues.RowMargin },
};

/// <summary>
/// Creates a remotable visual element representing the content of the margin.
/// </summary>
public async Task<IRemoteUserControl> CreateVisualElementAsync(ITextViewSnapshot textView, CancellationToken cancellationToken)
{
    var documentSnapshot = await textView.GetTextDocumentAsync(cancellationToken);
    var dataModel = new WordCountData();
    dataModel.WordCount = CountWords(documentSnapshot);
    this.dataModels[textView.Uri] = dataModel;
    return new MyMarginContent(dataModel);
}

除了設定邊界位置之外,文字檢視邊界提供者也可以設定使用 GridCellLengthGridUnitType 屬性,放置邊界之方格的大小。

文字檢視邊界通常會將與文字檢視相關的某些資料可視化 (例如,目前的行號或錯誤計數),因此大部分的文字檢視邊界提供者也會想要接聽文字檢視事件,以回應文字檢視的開啟、關閉和使用者輸入。

Visual Studio 只會建立文字檢視邊界提供者的一個執行個體,而不論使用者開啟多少個適用的文字檢視,因此如果您的邊界顯示一些具狀態的資料,您的提供者必須保留目前開啟的文字檢視的狀態。

如需詳細資訊,請參閱字數邊界範例

尚不支援其內容必須與文字檢視行對齊的垂直文字檢視邊界。

了解編輯器概念中的編輯器介面和類型。

檢閱簡單編輯器型擴充功能的範例程式碼:

進階使用者可能想要了解編輯器 RPC 支援