From ab4a7676b9d396aae10f116982013eb2ae3ea617 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 27 Oct 2026 02:21:14 +0100 Subject: [PATCH 1/9] Switch the solution to the `.slnx` format `dotnet sln migrate` produced `StringResourceVisualizer.slnx` from `StringResourceVisualizer.sln`, and the old file is removed. The projects and the solution items are the same. The project ids are the ones of the old solution, which is what Visual Studio writes into the file itself. The `.slnx` format is XML, much shorter than the `.sln`, and designed to reduce merge conflicts (https://devblogs.microsoft.com/visualstudio/new-simpler-solution-file-format/). Building it needs the .NET SDK 9.0.200 or later, or MSBuild from Visual Studio or Build Tools 17.13 or later. Co-Authored-By: Claude Sonnet 5.5 --- StringResourceVisualizer.sln | 38 ----------------------------------- StringResourceVisualizer.slnx | 9 +++++++++ 2 files changed, 9 insertions(+), 38 deletions(-) delete mode 100644 StringResourceVisualizer.sln create mode 100644 StringResourceVisualizer.slnx diff --git a/StringResourceVisualizer.sln b/StringResourceVisualizer.sln deleted file mode 100644 index 90c2db1..0000000 --- a/StringResourceVisualizer.sln +++ /dev/null @@ -1,38 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32104.313 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringResourceVisualizer", "src\StringResourceVisualizer.csproj", "{E71A6E00-D6F7-4401-9515-1DD72701EC57}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F0206BFC-99B1-481B-8248-F4C4995858FC}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - CHANGELOG.md = CHANGELOG.md - README.md = README.md - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StringResourceVisualizer.Tests", "src\StringResourceVisualizer.Tests\StringResourceVisualizer.Tests.csproj", "{B36B6804-3DE4-4C5B-9727-25F053CBA7AC}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E71A6E00-D6F7-4401-9515-1DD72701EC57}.Release|Any CPU.Build.0 = Release|Any CPU - {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B36B6804-3DE4-4C5B-9727-25F053CBA7AC}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {CD6B32D1-9303-4C28-8469-72A05632ABE0} - EndGlobalSection -EndGlobal diff --git a/StringResourceVisualizer.slnx b/StringResourceVisualizer.slnx new file mode 100644 index 0000000..d24ac8f --- /dev/null +++ b/StringResourceVisualizer.slnx @@ -0,0 +1,9 @@ + + + + + + + + + From 9bda92d78035b4cfc645652af208096a07fae955 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:04 +0100 Subject: [PATCH 2/9] Add the instructions for AI agents `.github/copilot-instructions.md` is the single source for GitHub Copilot, Claude Code and other agents: the build and test commands, the structure, how the adornments work, the parts that belong to the original author, the rules for Visual Studio extensions and the C# guidelines. `CLAUDE.md` imports it and `AGENTS.md` points to it. Co-Authored-By: Claude Sonnet 5.5 --- .github/copilot-instructions.md | 150 ++++++++++++++++++++++++++++++++ AGENTS.md | 3 + CLAUDE.md | 5 ++ 3 files changed, 158 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..230b9ba --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,150 @@ +# Copilot Instructions + +## Project Details + +* String Resource Visualizer – a Visual Studio extension (VSIX) that shows the text of a string resource (`.resx`) above the line of C#, Visual Basic or Razor code that uses it; the features are described in #file:'README.md', the history in #file:'CHANGELOG.md' +* Installs into Visual Studio 2022 from 17.10 up to, but not including, 18.0, on `amd64` and `arm64` (see #file:'src/source.extension.vsixmanifest'), so Visual Studio 2026 is not a target; .NET Framework 4.8.1; `LangVersion` is 12.0 (set in #file:'Directory.Build.props'). The Visual Studio range is written in several places that must change together: the two `InstallationTarget` elements of the manifest (one per architecture), its `CoreEditor` prerequisite, `MinimumVisualStudioVersion` (15.0) in the project file and the badges of the README, which still include one for Visual Studio 2019 +* The solution is #file:'StringResourceVisualizer.slnx', in the XML `.slnx` format. It needs the .NET SDK 9.0.200 or later, or the MSBuild of Visual Studio or Build Tools 17.13 or later (`dotnet sln` edits it). It has two old-style (non-SDK) projects: the extension #file:'src/StringResourceVisualizer.csproj' and its tests #file:'src/StringResourceVisualizer.Tests/StringResourceVisualizer.Tests.csproj'. Every `.cs`, content and resource file is listed in them by hand, so a new file is neither compiled nor packaged until it is added there. Package versions are `PackageReference` items in the same files – there is no central package management, and #file:'nuget.config' maps every package to nuget.org +* Windows only: building needs Visual Studio with the "Visual Studio extension development" workload and the .NET Framework 4.8.1 targeting pack (without the pack the build fails with `MSB3644`). Use the MSBuild of Visual Studio; `dotnet build` does not build VSIX projects +* Build from the repository root in PowerShell. `msbuild` is usually not on `PATH`, so find it with `vswhere` (add `-version '[17.0,18.0)'` to build with Visual Studio 2022 instead of the latest one; the CI runs on `windows-2022`, which has Visual Studio 2022); `DeployExtension=false` keeps the build from installing the extension into the Experimental instance: + + ```powershell + $msbuild = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1 + & $msbuild StringResourceVisualizer.slnx -restore -p:Configuration=Debug -p:DeployExtension=false -v:m + ``` + +* Test: `src/StringResourceVisualizer.Tests` is an MSTest project with four tests of `ResourceAdornmentManager.FormatDisplayText`. Run it from Test Explorer, or after a build with the `vstest.console.exe` of Visual Studio: + + ```powershell + $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -property installationPath + & "$vs\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" src\StringResourceVisualizer.Tests\bin\Debug\StringResourceVisualizer.Tests.dll + ``` + + Everything else (package load, adornments, the options page) has no automated tests: run the extension in the Visual Studio Experimental instance (F5 starts `devenv.exe /rootsuffix Exp`, see the start options of the project) and open a solution that has a `.resx` file and code that uses it +* CI: GitHub Actions (#file:'.github/workflows/build.yaml') builds the solution in `Debug` on `windows-2022` for pushes and pull requests to `main` (changes to root-level `.md` files only are ignored), runs the tests through `josepho0918/vstest-action` and uploads the VSIX as an artifact. A step stamps the run number into the version in the manifest and in `Vsix.Version` of `src/source.extension.cs`, so change versions by hand only for a deliberate release bump. The last steps publish a test badge to a gist of the original author; the upload runs on `main` only and needs a `GIST_TOKEN` secret that a fork does not have + +## Solution Structure + +**IMPORTANT: Every new source file must be added to #file:'src/StringResourceVisualizer.csproj'** (or to the test project): `` for code and `` with `IncludeInVSIX` for files that go into the VSIX. A file that is created outside Visual Studio is not added automatically. + +```text +/ +├── .claude/ – Claude Code settings (the vs-agent-plugins plugin) and helper scripts; `skills` is a local, git-ignored link to `.github/skills` +├── .github/ – these instructions, the `vs-extension-expert` agent, skills, prompts, issue templates and workflows (the CI build among them) +├── art/ – images for the README +├── src/ – the extension and its tests +│ ├── StringResVizPackage.cs – the `AsyncPackage`: option page, solution events, running document table events, the list of `.resx` files and the file watchers +│ ├── ResourceAdornmentManagerFactory.cs – MEF export that creates a `ResourceAdornmentManager` for each text view and defines the adornment layer `StringResourceCommentLayer` +│ ├── ResourceAdornmentManager.cs – loads the `.resx` files as `XmlDocument`s, finds the usages on the lines of a text view and adds a `TextBlock` above the line +│ ├── MyLineTransformSourceProvider.cs – MEF export of the `ILineTransformSourceProvider` that creates the `MyLineTransformSource` +│ ├── MyLineTransformSource.cs – makes room above the lines that have an adornment +│ ├── MyRunningDocTableEvents.cs – reloads the known constants when a document is saved or its window is shown +│ ├── ConstFinder.cs – collects the `const` fields of the solution through the Roslyn workspace, for the ILocalizer support +│ ├── OptionsGrid.cs – the Tools > Options > String Resource Visualizer page +│ ├── OutputPane.cs – the "String Resource Visualizer" pane of the Output window +│ ├── Messenger.cs – the event that asks for a reload of the resources +│ ├── Constants.cs, StringExtensions.cs, TaskExtensions.cs – helpers (`LogAndForget` is in `TaskExtensions.cs`) +│ ├── SponsorDetector.cs, SponsorRequestHelper.cs, AnalyticsConfig.cs – sponsorship message and telemetry settings of the original author (see below) +│ ├── signvsix.targets, filestosign.txt – signing of the release build; runs only when `SM_CERT_FP_SHA256` is set +│ ├── GlobalSuppressions.cs, StringResourceVisualizer.ruleset, stylecop.json – code analysis settings +│ ├── source.extension.vsixmanifest / .cs – the manifest and the file generated from it +│ ├── Properties/AssemblyInfo.cs – assembly attributes; the version comes from the `Vsix` constants +│ ├── Resources/Icon.png +│ ├── App.config +│ ├── StringResourceVisualizer.csproj +│ └── StringResourceVisualizer.Tests/ – the MSTest project +├── CHANGELOG.md, CONTRIBUTING.md, LICENSE, README.md +├── Directory.Build.props – `LangVersion` +├── Directory.Build.targets – links `.claude/skills` to `.github/skills` +├── nuget.config +└── StringResourceVisualizer.slnx +``` + +### How It Works + +* `StringResVizPackage` is an `AsyncPackage` that loads in the background when a C# project is loaded (`ProvideAutoLoad` names only that context). `InitializeAsync` switches to the UI thread, handles a solution that is already open, subscribes to `SolutionEvents.OnAfterOpenSolution` and `OnAfterCloseSolution`, advises the running document table, parses the solution for constants, reads the font size and the color of strings from the Fonts and Colors settings (again whenever the theme changes), shows the sponsorship message and sends the usage telemetry. `StringResVizPackage.Instance` is assigned near the end of `InitializeAsync`, so code that can run earlier, such as a text view that is created while the package loads, must not assume that it is set: the constructor of `ResourceAdornmentManager` loads the package through `IVsShell.LoadPackage` when `Instance` is still `null` (which is how it loads for other solutions), and `MyLineTransformSource` reads `Instance.Options` without a check. +* When a solution opens, `HandleOpenSolutionAsync` finds the solution (or project) folder and `SetOrUpdateListOfResxFilesAsync` enumerates all `.resx` files below it. A file counts as neutral-language when its name has no dot before the extension, so `Shared.Strings.resx` is skipped unless it ends in the preferred culture, which is used when one is set. `ResourceAdornmentManager.LoadResourcesAsync` loads the files as `XmlDocument`s, and `FileSystemWatcher`s on the solution, the project files and the `.resx` files reload them when they change. Closing the options page asks for a reload through `Messenger`. +* Every document text view (role `Document`) of the content types `CSharp`, `Basic`, `Razor`, `RazorCSharp` and `RazorCoreCSharp` gets a `ResourceAdornmentManager` (one per view, kept in the view's `Properties`): `ResourceAdornmentManagerFactory` creates it in `TextViewCreated`, and `MyLineTransformSourceProvider` creates it too when it is the first to run. The manager handles `LayoutChanged`: for the new or reformatted lines it looks for `.` (and for namespace aliases when that option is on), reads the key that follows, finds the text in the loaded documents (preferred culture first) and adds a `TextBlock` with `"text"` to the adornment layer, positioned at the usage. `FormatDisplayText` shortens a value with line breaks and marks the cut with the return symbol (U+23CE); its tests show the exact rules. +* ASP.NET Core localizer support (experimental, on by default): a line that contains one of the identifiers in the option (default `localizer[` and `loc[`) is read as `localizer["Key"]` or `localizer[SomeConst]`. A constant is resolved through `ConstFinder`, and the resource file is chosen by how closely its path resembles the code file (`GetLocalizerDocsOfInterest`). +* `MyLineTransformSource` adds the space above a line that has an adornment: the default top space, plus the top and bottom padding options scaled by `Constants.TextBlockSizeToFontScaleFactor`, plus the editor font size. The height of the `TextBlock` is the font size times the same factor plus the padding; keep the two calculations consistent when you change one. The expression for the `TextBlock` height has an operator-precedence slip (`a + b ?? 0 + c ?? 0`), so the bottom padding is never added to it. +* `ConstFinder` walks the syntax trees of every project of the `VisualStudioWorkspace` and keeps the `const` fields in `KnownConsts` as `(key, qualification, value, source)`; it yields between steps so that a large solution does not freeze the UI. `MyRunningDocTableEvents` refreshes the constants of the active document on save and when a document window is shown. +* `ResourceAdornmentManager` keeps `ResourceFiles`, `SearchValues`, `XmlDocs`, `ResourcesLoaded` and `ResxWatcher` in static members that are shared by all text views. `LoadResourcesAsync` and `ReloadResourceFileAsync` change them on background threads while the layout handler on the UI thread reads them; only the `ResourcesLoaded` flag guards that. `HandleOpenSolutionAsync` adds a new `Messenger.ReloadResources` handler each time a solution opens and nothing removes the old ones, so after several solutions, closing the options page reloads the resources for every earlier root folder. Keep both in mind before you change the loading code. +* `OutputPane.Instance` creates the pane on first use and its constructor requires the UI thread, so the first access must happen there. + +### Parts of the Original Author + +The publisher identity in the manifest, the sponsorship message (`SponsorDetector`, `SponsorRequestHelper`), the telemetry (`AnalyticsConfig.TelemetryConnectionString` is empty in the repository, and `TrackBasicUsageAnalyticsAsync` sends data only in non-`DEBUG` builds when it is set) and the signing (`signvsix.targets`) belong to Matt Lacey. Do not change or remove them unless the task asks for it. `SponsorDetector.IsSponsorAsync` says in a comment that the released extension is built with a different implementation. + +## Visual Studio Extension Rules + +* Threading: use Visual Studio COM objects (`DTE`, `IVs*` services) and WPF elements on the UI thread only. Switch with `await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken)` (`this.JoinableTaskFactory` inside the package) before you create or call such an object. Never block with `.Result` or `.Wait()`. The two callbacks of `MyRunningDocTableEvents` that call `ThreadHelper.JoinableTaskFactory.Run` are the existing exception, because `IVsRunningDocTableEvents` is synchronous – do not add more. Keep the work per line in `LayoutChangedHandler` small: it runs for every layout of every text view. The VSTHRD analyzers come with `Microsoft.VisualStudio.SDK`: fix what they report, and where a suppression is unavoidable use a narrow `#pragma warning disable` / `restore` pair, as the existing code does for `async void` handlers +* Errors: never swallow a caught exception – write it to the Output pane with `OutputPane.Instance.WriteAsync` (message, source and stack trace, as the existing handlers do), or hand a faulted task to `TaskExtensions.LogAndForget`, which writes to the activity log. Never let an exception escape from an event handler into Visual Studio: the `async void` handlers catch and log (`LayoutChangedHandler` catches only `InvalidOperationException` itself and relies on the catch-all inside `CreateVisualsAsync`). `async void` is for event handlers only. A few existing `catch` blocks only call `Debug.WriteLine` on purpose and say why in a comment; a new one needs the same justification +* Theming: the adornment takes its font size from the Fonts and Colors setting of the text editor and its color from the setting for strings (`ResourceAdornmentManager.TextSize` and `TextForegroundColor`, refreshed on `VSColorTheme.ThemeChanged`); never hard-code colors or fonts. The gray and the size 10 are only the values used until the settings are read +* Editor extensibility: the exports are MEF parts of the extension's own assembly, declared as the `MefComponent` asset in the manifest. The adornment layer name `StringResourceCommentLayer` is written twice, in the `[Name]` of the layer definition and in `view.GetAdornmentLayer(...)`, and the `[ContentType]` lists of `ResourceAdornmentManagerFactory` and `MyLineTransformSourceProvider` must stay equal; a mismatch compiles and fails or silently does nothing at run time +* Generated files: `src/source.extension.cs` carries an `` header and is made from the manifest by the `VsixManifestGenerator` custom tool set in the project file, which needs the VSIX Synchronizer extension. MSBuild and agents cannot run it, so edit the file by hand and keep it in sync with `source.extension.vsixmanifest` (they are in sync now) +* Failures that appear only at run time: a missing manifest asset (such as the `MefComponent` one), a mismatched layer name or content type, or a missing `` item compile fine and break the extension later. Check such changes in the Experimental instance +* .NET Framework 4.8.1: language features that need no runtime support work (file-scoped namespaces, pattern matching, switch expressions, target-typed `new`, `is not null`); `record` and `init` need an `IsExternalInit` polyfill type, which the project does not have. The framework has no `Index`/`Range` or default interface members. `Span`, `Memory` and `IAsyncEnumerable` compile only because the packages that the project restores bring `System.Memory` and `Microsoft.Bcl.AsyncInterfaces`; do not add a polyfill package for something that already compiles, and check that an API exists in .NET Framework 4.8.1 before using it +* Package versions: Visual Studio loads its own copy of some libraries. The VSIX does not contain `Microsoft.CodeAnalysis.dll`, `Microsoft.CodeAnalysis.CSharp.dll` or `Microsoft.VisualStudio.LanguageServices.dll`, so the extension runs against the Roslyn of the Visual Studio that hosts it, and a Roslyn API that is newer than the oldest supported Visual Studio (17.10) fails at run time. `Microsoft.VisualStudio.SDK` is 17.10.40171, the same as the minimum Visual Studio in the manifest; keep the two aligned, and check the build warnings and run the extension before you raise a version + +## Libraries in Use + +* [Visual Studio SDK](https://learn.microsoft.com/visualstudio/extensibility/) (`Microsoft.VisualStudio.SDK` 17.10.40171) – the shell, text editor and threading APIs, `EnvDTE`, the interop assemblies and the VSTHRD analyzers +* `Microsoft.CodeAnalysis`, `Microsoft.CodeAnalysis.CSharp` and `Microsoft.VisualStudio.LanguageServices` 4.11.0 – the Roslyn workspace that `ConstFinder` reads +* `Microsoft.ApplicationInsights` – the usage telemetry of the release build +* `MessagePack`, `StreamJsonRpc`, `Microsoft.ServiceHub.Framework`, `Microsoft.IO.Redist` and several `System.*` packages – dependencies that the project pins explicitly +* `WarnAboutTODOs` – turns each `TODO` comment into a build warning +* MSTest with `MSTest.Analyzers` (3.6.3) in the test project + +## Agents and Skills + +* Use the `vs-extension-expert` agent (#file:'.github/agents/vs-extension-expert.agent.md') for work on the extension code. +* Skills live in `.github/skills`; `Directory.Build.targets` links the folder to the git-ignored `.claude/skills` for Claude Code. They are copied from [`vs-agent-plugins`](https://github.com/madskristensen/vs-agent-plugins) by Mads Kristensen, see #file:'.github/skills/README.md': `handling-async-threading`, `handling-extension-errors`, `adding-editor-adornments`, `listening-text-view-events`, `adding-options-settings` and `handling-solution-events`. When a task matches the domain of a skill, load it and follow it before you write code. +* `.claude/settings.json` also enables the whole catalogue (40 skills) as the Claude Code plugin `vs-extensibility-skills`; if Claude Code has not offered it, install it with `claude plugin install vs-extensibility-skills@vs-agent-plugins --scope project`. +* The skills describe three approaches: VisualStudio.Extensibility (out of process), Community.VisualStudio.Toolkit and VSSDK. This extension is in-process VSSDK (`AsyncPackage`) without the Toolkit, so follow the VSSDK parts and ignore the others. + +## MCP Servers + +Agents discover servers from #file:'.mcp.json'; these are only hints on when to prefer one. + +* Use the `GitHub` server for code search in dependency repositories. In Claude Code it authenticates through `.claude/scripts/github-mcp-headers.ps1`, which reuses the GitHub token stored by Git Credential Manager, because the server does not support the OAuth dynamic client registration Claude Code needs. +* Use the `Microsoft Learn` server for official Microsoft documentation, the Visual Studio extensibility documentation included. Visual Studio bundles this server itself, so `.mcp.json` lists it for Claude Code only. +* When the solution is open in Visual Studio and an MCP server that exposes the IDE (`vs`) is connected, prefer its tools to the shell: build with it instead of resolving MSBuild, read the diagnostics from it instead of parsing the build output, and navigate with its symbol tools instead of text search. +* When adding or changing a server, keep `servers` (Visual Studio / VS Code / GitHub Copilot) and `mcpServers` (Claude Code) in sync and sorted alphabetically by key, except for the servers that Visual Studio already bundles (Microsoft Learn, NuGet): those go only in `mcpServers`. + +## Definition of Done + +Before reporting a task as finished: + +1. Match the style of the surrounding code; the `.editorconfig` only silences the StyleCop rule `SA0001`. +2. Build the solution and make sure that it succeeds with no errors; do not claim a successful build that you did not run. +3. Compare the warnings with the build before your change: the change must add none. The build already reports warnings (the NuGet audit warnings `NU1902` and `NU1903` for `MessagePack`, `VSTHRD110` and the `TODO` comments through `WarnAboutTODOs`), so compare instead of expecting zero. +4. Run the tests when the change touches code that they cover, and add a test for logic that does not depend on Visual Studio. Say what you could not verify without the Experimental instance instead of claiming that it works: the adornments, the options page and the package load are not covered by tests. +5. Update `README.md` and `CHANGELOG.md` when a user-visible feature changes. The change log has a road map and the list of released versions; its newest entry (1.23.1) is older than the manifest version (1.23.3). + +## C# Coding Guidelines + +* Match the file that you edit; do not reformat code that you do not change. The conventions come from the code, because the `.editorconfig` has no style rules. Most `.cs` files are indented with 4 spaces; `StringResVizPackage.cs`, `ResourceAdornmentManager.cs` and `AnalyticsConfig.cs` use tabs, and `SponsorRequestHelper.cs` mixes both. Most `.cs` files start with a UTF-8 byte order mark, which you keep. The repository stores LF line endings (`* text=auto` in `.gitattributes`); a Windows checkout with `core.autocrlf=true` shows CRLF. +* New files start with the copyright header block of the existing files (`// `). +* Qualify instance members with `this.`, as the existing code does. Put `using` directives above the namespace (`System` first in most files) and remove unused ones. Several files carry `using Task = System.Threading.Tasks.Task;` from the package template; leave it where it is. +* Types live in the `StringResourceVisualizer` namespace (`StringResourceVisualizer.Tests` for the tests) in a block-scoped `namespace` declaration. Do not convert existing files. +* Name async methods with the `Async` suffix. Only the package methods take a `CancellationToken`; pass one on in new async code when the caller has one. +* XML documentation: StyleCop rules `SA1600`, `SA1611` and `SA1615` are suppressed on purpose, so documenting every member is not required, but document new public types and non-obvious members. The StyleCop rule IDs in `#pragma` lines, `GlobalSuppressions.cs` and the ruleset come from an earlier setup: the project file lists no StyleCop package. +* #file:'CONTRIBUTING.md' asks to run Code Analysis in `Debug` and to fix the findings before committing. The ruleset only lists legacy FxCop rule IDs, and the only analyzers that the project references are those of the Visual Studio SDK (VSTHRD and VSSDK), so in practice fix the analyzer warnings of the build. + +## Naming Conventions + +* Use PascalCase for types, methods and public members, and camelCase without a prefix for private fields (`layer`, `view`, `fileName`); `ConstFinder` uses a `_` prefix for its one private static field, so match the file. +* Prefix interface names with `I`. Name the files after the type they hold; the line transform classes are named `MyLineTransformSource` and `MyLineTransformSourceProvider`. + +## General + +* Make only high-confidence suggestions when reviewing code changes. +* Write code with good maintainability practices, including comments on why certain design decisions were made. +* Handle edge cases and write clear exception handling. +* Never duplicate code unless explicitly allowed. +* All comments, documentation, README files, and markdown files must be written in **English only**. +* Use an en dash (`–`) as a punctuation dash, never an em dash (`—`), and do not use emojis in text, comments or messages. +* In markdown, write each paragraph on one line instead of hard-wrapping it. +* Scripts and one-off automation: PowerShell 7 (`pwsh`, `.ps1`) or F# Interactive (`dotnet fsi`, `.fsx`); do not use Python. +* The default branch is `main`, and pull requests go against it (#file:'CONTRIBUTING.md' says `master`, which is a leftover of the template). +* Commit messages follow the history of this repository: one short sentence in the imperative, with no prefix (`Avoid crash if file opened outside of a project`). Older messages are often lower-case (`add version number to output pane`) and releases are plain version numbers (`v1.23.3`); #file:'CONTRIBUTING.md' asks for a feature prefix, a line from a template that the history does not follow. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ae0cbfc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Instructions + +Before starting any task, read and follow [.github/copilot-instructions.md](.github/copilot-instructions.md). It holds the full project context and coding standards. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5dab9eb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code Instructions + +The project instructions are shared with GitHub Copilot and maintained in a single place: + +@.github/copilot-instructions.md From c2933742e5a0dcbc8cd0fa10f0e0dc0521fab594 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:04 +0100 Subject: [PATCH 3/9] Add the prompt that writes pull request descriptions `create-pr-description.prompt.md` compares the branch with `origin/main` and fills in the pull request template. This repository has no template, so the prompt falls back to a default structure. Co-Authored-By: Claude Sonnet 5.5 --- .../prompts/create-pr-description.prompt.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/prompts/create-pr-description.prompt.md diff --git a/.github/prompts/create-pr-description.prompt.md b/.github/prompts/create-pr-description.prompt.md new file mode 100644 index 0000000..fa04f36 --- /dev/null +++ b/.github/prompts/create-pr-description.prompt.md @@ -0,0 +1,75 @@ +--- +mode: agent +description: Generates a pull request description for String Resource Visualizer by analyzing the current branch's changes and filling in the pull request template. +--- + +# Create PR Description + +## Context + +Pull request template: `.github/PULL_REQUEST_TEMPLATE.md`. This repository has none at the moment, so use the default structure under `## Output`. If the file exists, read it (for example `Get-Content .github/PULL_REQUEST_TEMPLATE.md` in PowerShell from the repository root) and follow it exactly instead. + +## Task + +Analyze the current branch's git diff and commit history relative to the remote base branch (`origin/main`, or the base that the user names, for example `mrlacey/main` for a pull request to the original repository), then produce a fully filled-in PR description that conforms to the template. + +## Steps + +1. Identify the exact section structure, checkbox labels, and required ordering: from the template when it exists, otherwise from the default structure under `## Output`. +2. Run `git diff origin/main...HEAD --stat`, `git diff origin/main...HEAD`, and `git log origin/main...HEAD --oneline` to determine exactly what changed in the current branch. +3. Fill `## Proposed Changes` with a concise 3–6 sentence summary of the concrete behavior, UI, option, or documentation changes visible in the diff. +4. Set `## Types of changes` checkboxes strictly from evidence in commits and diff: + - mark bugfix only when existing behavior is corrected + - mark new feature only when new user-facing capability is introduced + - mark breaking change only when existing behavior, settings, or supported Visual Studio versions are intentionally changed +5. Evaluate `## Checklist` strictly from branch evidence: + - the test project covers only `ResourceAdornmentManager.FormatDisplayText`: mark the tests item only if a passing run is visible from evidence, and never claim that the tests cover the adornments, the options page or the package load + - mark the build item only if a successful build is visible from evidence; otherwise leave it unchecked and add a brief inline note + - mark the Experimental instance item only if the user says that the change was verified there + - mark documentation-related items only if `README.md` and `CHANGELOG.md`, or XML comments were changed in the diff +6. Replace all placeholder/template text with branch-specific content and include `## Further comments` only when the change is large or architecturally significant. + +## Guidelines + +- Be factual and precise – base every statement on the actual diff and commits, not assumptions. +- Keep the **Proposed Changes** section concise but informative (3–6 sentences max). +- For checklist items that cannot be determined from the diff alone, leave them unchecked and add a short inline note. +- Do not invent issue numbers or links unless they appear in commit messages or branch names. +- Use plain English; avoid vague filler phrases like "various improvements". +- Do not start the summary with "This branch adds". +- Use only en dashes (`–`) for dashes; never use em dashes (`—`). +- Always wrap names and versions into backticks (for example, `ResourceAdornmentManager`) when referring to them in the description. +- Preserve all original template headings, checkbox syntax, and section order exactly. + +## Output + +A single fenced markdown code block containing the fully filled-out PR description, ready to copy and paste directly into GitHub. Do not escape any markdown syntax inside the block. Match the structure of the template exactly, or this default structure when there is no template: + +```markdown +## Proposed Changes + +Narrative paragraph(s). + +## Types of changes + +_Put an `x` in the boxes that apply_ + +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) + +## Checklist + +- [ ] The solution builds locally without new warnings +- [ ] The tests pass +- [ ] The change was verified in the Visual Studio Experimental instance +- [ ] I have updated `README.md` and `CHANGELOG.md` (if appropriate) + +## Further comments + +Include this section only when the change warrants extra explanation; omit it entirely otherwise. +``` + +Put the `x` into the correct boxes from the evidence, as described in the steps above. + +Do not include any explanation or commentary outside the code block. From df9be47b48ff64616adda34cb29513bb51bcf22c Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:05 +0100 Subject: [PATCH 4/9] Add the `vs-extension-expert` agent A custom agent for the extension code, based on `madskristensen/CustomAgents`. It refers to the project instructions and to the skills instead of repeating them. Co-Authored-By: Claude Sonnet 5.5 --- .github/agents/vs-extension-expert.agent.md | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/agents/vs-extension-expert.agent.md diff --git a/.github/agents/vs-extension-expert.agent.md b/.github/agents/vs-extension-expert.agent.md new file mode 100644 index 0000000..dc609fd --- /dev/null +++ b/.github/agents/vs-extension-expert.agent.md @@ -0,0 +1,42 @@ +--- +name: vs-extension-expert +description: Expert in Visual Studio extension (VSIX) development for String Resource Visualizer – the AsyncPackage, MEF text view exports and adornments, VS threading rules, the Roslyn workspace and the options page. Use it for changes to the extension code, the manifest and the project files. +--- + +You are a **Visual Studio extension (VSIX) engineer** who maintains String Resource Visualizer: an in-process, VSSDK-based extension for Visual Studio 2022 (17.10 and later, below 18.0) on .NET Framework 4.8.1 that shows the text of `.resx` string resources above the code that uses them. + +Your scope is the extension itself: `.cs`, `.vsixmanifest`, `.csproj`, the build files and the MSTest project. Do not change documentation, CI or unrelated files unless the task asks for it. + +## Start here + +1. Read #file:'.github/copilot-instructions.md'. It holds the build and test commands, the structure, how the adornments work and the Visual Studio rules in full, and it wins over anything in this file. +2. Load the skill that matches the task from `.github/skills` before you write code: + * `handling-async-threading` – async code, UI-thread switches, `JoinableTaskFactory` + * `handling-extension-errors` – logging and exception handling + * `adding-editor-adornments` – adornment layers, `IWpfTextViewCreationListener`, positioning + * `listening-text-view-events` – layout, text and caret events of a text view + * `adding-options-settings` – the options page + * `handling-solution-events` – solution open and close events + + Each skill describes VisualStudio.Extensibility, Community.VisualStudio.Toolkit and VSSDK. This project is in-process VSSDK without the Toolkit, so follow the VSSDK parts. +3. When the `vs` MCP server is connected, use it: `ide_get_diagnostics` and the `nav_*` tools instead of text search, `build_solution` instead of MSBuild. + +## What to guarantee + +* Responsive editor: `LayoutChanged` fires for every layout of every text view, so keep the work per line small, never block, and do file and Roslyn work off the UI thread. +* Thread-safe: COM objects and WPF elements are touched on the UI thread only, switched to with `JoinableTaskFactory`; never `.Result` or `.Wait()` in new code. +* Reliable: exceptions never escape from event handlers into Visual Studio; a `.resx` file can be locked, malformed or missing, and a solution can be closing while the parse runs; check the result of `GetServiceAsync` and of `as` casts for `null`. +* Themed: the adornment uses the editor font size and the color of strings from the Fonts and Colors settings; no hard-coded colors or fonts. +* Consistent: the adornment layer name and the `[ContentType]` lists exist in more than one place and must change together; the author's sponsorship, telemetry and signing code stays as it is unless the task says otherwise. +* Maintainable: match the style of the file that you edit (indentation differs between files). + +## Analyzers + +The VSTHRD (threading) analyzers are active in this project. Fix what they report instead of suppressing it; where a suppression is unavoidable use a narrow `#pragma warning disable` / `restore` pair, as the existing code does. The ones that matter most here: VSTHRD010 (COM objects on the UI thread), VSTHRD100 (no `async void` except event handlers), VSTHRD110 (observe the result of async calls) and VSTHRD200 (`Async` suffix). + +## How to work + +1. Read the code that you change and the code that calls it. +2. Change every place that the task touches together: a miss compiles and fails or does nothing at run time. +3. Build, run the tests, compare the warnings with the build before your change, and fix the ones that you introduced. +4. Report what you changed and what you could not verify: the tests cover only `FormatDisplayText`, so the adornments, the options page and the package load need the Experimental instance with a solution that has `.resx` files. From 5dac38a0c930905f78cf606f405ef9ee7b656b12 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:06 +0100 Subject: [PATCH 5/9] Add the Visual Studio extension skills Six skills of `madskristensen/vs-agent-plugins` (commit e971073e2163d4bc66d3e455e102c316e01c94c6) that apply to this extension: `handling-async-threading`, `handling-extension-errors`, `adding-editor-adornments`, `listening-text-view-events`, `adding-options-settings` and `handling-solution-events`. The content is unchanged. The Apache License 2.0 of the upstream repository comes with them, and `README.md` names the source. Co-Authored-By: Claude Sonnet 5.5 --- .github/skills/LICENSE | 190 +++++ .github/skills/README.md | 18 + .../skills/adding-editor-adornments/SKILL.md | 317 ++++++++ .../skills/adding-options-settings/SKILL.md | 550 ++++++++++++++ .../skills/handling-async-threading/SKILL.md | 702 ++++++++++++++++++ .../skills/handling-extension-errors/SKILL.md | 374 ++++++++++ .../skills/handling-solution-events/SKILL.md | 515 +++++++++++++ .../listening-text-view-events/SKILL.md | 289 +++++++ 8 files changed, 2955 insertions(+) create mode 100644 .github/skills/LICENSE create mode 100644 .github/skills/README.md create mode 100644 .github/skills/adding-editor-adornments/SKILL.md create mode 100644 .github/skills/adding-options-settings/SKILL.md create mode 100644 .github/skills/handling-async-threading/SKILL.md create mode 100644 .github/skills/handling-extension-errors/SKILL.md create mode 100644 .github/skills/handling-solution-events/SKILL.md create mode 100644 .github/skills/listening-text-view-events/SKILL.md diff --git a/.github/skills/LICENSE b/.github/skills/LICENSE new file mode 100644 index 0000000..f51f100 --- /dev/null +++ b/.github/skills/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Mads Kristensen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.github/skills/README.md b/.github/skills/README.md new file mode 100644 index 0000000..684fb5e --- /dev/null +++ b/.github/skills/README.md @@ -0,0 +1,18 @@ +# Agent skills + +These skills teach agents how to build Visual Studio extensions. They are copied unchanged from [`madskristensen/vs-agent-plugins`](https://github.com/madskristensen/vs-agent-plugins) at commit `e971073e2163d4bc66d3e455e102c316e01c94c6`; the upstream is licensed under the Apache License 2.0, and its [`LICENSE`](LICENSE) is next to this file. + +| Skill | Use it for | +|---|---| +| `handling-async-threading` | async/await, UI-thread switches, `JoinableTaskFactory` | +| `handling-extension-errors` | exceptions, the Output window, the activity log | +| `adding-editor-adornments` | adornment layers and WPF visuals on top of the editor text | +| `listening-text-view-events` | creation, layout, text and caret events of a text view | +| `adding-options-settings` | Tools > Options pages and settings | +| `handling-solution-events` | solution and project open, close and load events | + +Only the skills that apply to this extension are copied, so some of them link to skills of the upstream catalogue that are not in this folder. The whole catalogue (40 skills) is the Claude Code plugin `vs-extensibility-skills` that `.claude/settings.json` enables, so Claude Code may list the skills above twice: once from this folder and once from the plugin. + +`Directory.Build.targets` links this folder to `.claude/skills` (git-ignored) for Claude Code; GitHub Copilot in Visual Studio 2026 (version 18.5 or later) reads the skills from here. + +To update a skill, copy its `SKILL.md` from a newer commit of the upstream repository and change the commit above. diff --git a/.github/skills/adding-editor-adornments/SKILL.md b/.github/skills/adding-editor-adornments/SKILL.md new file mode 100644 index 0000000..c309e59 --- /dev/null +++ b/.github/skills/adding-editor-adornments/SKILL.md @@ -0,0 +1,317 @@ +--- +name: adding-editor-adornments +description: Add visual adornments (decorations, overlays, highlights) to the Visual Studio text editor. Use when the user asks how to draw on the editor, add inline decorations, highlight text visually, add background colors to lines, show icons in the editor, create viewport-relative overlays, or render custom WPF visuals on top of editor text. Covers VisualStudio.Extensibility (out-of-process), VSIX Community Toolkit (in-process), and legacy VSSDK (in-process) approaches. +--- + +# Editor Adornments in Visual Studio Extensions + +Adornments are WPF visuals drawn on the text editor surface. They can highlight text, add inline icons, render overlays, or display viewport-relative UI. Common scenarios: + +- Highlight specific lines or text spans with a colored background +- Draw inline color swatches next to color literals +- Show an image or icon next to a code element +- Add a viewport-relative watermark or status overlay + +Adornments are one of the most powerful editor integration points — they let you draw arbitrary WPF content directly on the code surface without modifying the underlying text. The critical constraint is performance: `LayoutChanged` fires on every scroll and edit, so adornment rendering must be fast. + +**When to use adornments vs. alternatives:** +- Visual highlights, overlays, or decorations on the editor surface → **adornments** (this skill) +- Syntax coloring (keyword highlighting, language coloring) → classifier (see [vs-editor-classifier](../adding-editor-classifiers/SKILL.md)) +- Gutter icons (per-line glyphs) → margin with `IGlyphFactory` (see [vs-editor-margin](../adding-editor-margins/SKILL.md)) +- Hover tooltips on text → Quick Info (see [vs-editor-quickinfo](../adding-quickinfo-tooltips/SKILL.md)) +- Inline metadata above code elements → CodeLens (see [vs-codelens](../adding-codelens-indicators/SKILL.md)) + +## Implementation checklist + +- [ ] Add the MEF asset type to `.vsixmanifest` +- [ ] Define the adornment layer (`[Export(typeof(AdornmentLayerDefinition))]`) +- [ ] Create the adornment class that adds WPF visuals to the layer +- [ ] Create the text view creation listener to instantiate the adornment on editor open + +--- + +## MEF Asset Type Requirement + +**Any extension that uses MEF editor exports must declare the MEF asset type in the `.vsixmanifest` file.** Without this, Visual Studio will not discover your MEF components and your adornment will not load. + +Add this inside the `` element of `source.extension.vsixmanifest`: + +```xml + +``` + +If you are using a multi-project solution, replace `%CurrentProject%` with the project name that contains the MEF exports. The Visual Studio VSIX project template usually includes this automatically, but if your adornment is not loading, this is the first thing to check. + +--- + +## 1. VisualStudio.Extensibility (out-of-process, recommended) + +The VisualStudio.Extensibility SDK does **not** currently support editor adornments. There is no out-of-process API for rendering custom WPF visuals on the editor surface. + +**If you need editor adornments, use the VSIX Community Toolkit or VSSDK (in-process) approach.** + +For some visual scenarios, you may be able to use `ITextViewChangedListener` combined with diagnostics/text markers in the new model, but full custom WPF adornments require in-process MEF. + +--- + +## 2. VSIX Community Toolkit (in-process) + +The Community Toolkit does not add a separate adornment API — adornments use the same MEF-based VSSDK APIs. The toolkit simplifies package initialization, but the adornment itself uses `IWpfTextViewCreationListener` and `AdornmentLayer` from the editor MEF model. + +**NuGet packages:** `Community.VisualStudio.Toolkit`, `Microsoft.VisualStudio.Editor`, `Microsoft.VisualStudio.Text.UI.Wpf` +**Key namespaces:** `Microsoft.VisualStudio.Text.Editor`, `Microsoft.VisualStudio.Utilities` + +The code below is identical to the VSSDK approach — the toolkit does not wrap this API. + +--- + +## 3. VSSDK (in-process, legacy) + +Editor adornments are created by: + +1. Defining an adornment layer with `[Export(typeof(AdornmentLayerDefinition))]` +2. Implementing `IWpfTextViewCreationListener` to attach your adornment logic when an editor opens +3. Drawing WPF elements on the adornment layer in response to layout changes + +**NuGet packages:** `Microsoft.VisualStudio.SDK`, `Microsoft.VisualStudio.Editor`, `Microsoft.VisualStudio.Text.UI.Wpf` +**Key namespaces:** `Microsoft.VisualStudio.Text.Editor`, `Microsoft.VisualStudio.Text.Formatting`, `Microsoft.VisualStudio.Utilities` + +### Step 1: Define the adornment layer and listener + +```csharp +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; + +namespace MyExtension; + +[Export(typeof(IWpfTextViewCreationListener))] +[ContentType("text")] +[TextViewRole(PredefinedTextViewRoles.Document)] +internal sealed class HighlightAdornmentFactory : IWpfTextViewCreationListener +{ + [Export(typeof(AdornmentLayerDefinition))] + [Name("MyHighlightAdornment")] + [Order(After = PredefinedAdornmentLayers.Selection, Before = PredefinedAdornmentLayers.Text)] + private AdornmentLayerDefinition _editorAdornmentLayer; + + public void TextViewCreated(IWpfTextView textView) + { + // Create the adornment manager and attach it to the view + new HighlightAdornment(textView); + } +} +``` + +### Step 2: Implement the adornment logic + +This example highlights every occurrence of "TODO" with a colored background: + +```csharp +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Formatting; + +namespace MyExtension; + +internal sealed class HighlightAdornment +{ + private const string AdornmentLayerName = "MyHighlightAdornment"; + private readonly IAdornmentLayer _layer; + private readonly IWpfTextView _view; + private readonly Brush _brush; + + public HighlightAdornment(IWpfTextView view) + { + _view = view; + _layer = view.GetAdornmentLayer(AdornmentLayerName); + + _brush = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0xFF, 0x00)); + _brush.Freeze(); + + _view.LayoutChanged += OnLayoutChanged; + } + + private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + foreach (ITextViewLine line in e.NewOrReformattedLines) + { + CreateVisuals(line); + } + } + + private void CreateVisuals(ITextViewLine line) + { + IWpfTextViewLineCollection textViewLines = _view.TextViewLines; + string text = line.Extent.GetText(); + + int index = 0; + while ((index = text.IndexOf("TODO", index, StringComparison.OrdinalIgnoreCase)) >= 0) + { + var span = new SnapshotSpan(_view.TextSnapshot, line.Start + index, 4); + Geometry geometry = textViewLines.GetMarkerGeometry(span); + + if (geometry != null) + { + var drawing = new GeometryDrawing(_brush, null, geometry); + drawing.Freeze(); + + var drawingImage = new DrawingImage(drawing); + drawingImage.Freeze(); + + var image = new Image + { + Source = drawingImage, + Width = geometry.Bounds.Width, + Height = geometry.Bounds.Height, + }; + + Canvas.SetLeft(image, geometry.Bounds.Left); + Canvas.SetTop(image, geometry.Bounds.Top); + + _layer.AddAdornment( + AdornmentPositioningBehavior.TextRelative, + span, + tag: null, + adornment: image, + removedCallback: null); + } + + index += 4; + } + } +} +``` + +### Adornment positioning behaviors + +| Behavior | Description | +|----------|-------------| +| `AdornmentPositioningBehavior.TextRelative` | Moves with the text it's attached to (most common) | +| `AdornmentPositioningBehavior.ViewportRelative` | Fixed position in the viewport (e.g., watermarks) | +| `AdornmentPositioningBehavior.OwnerControlled` | You manage the position manually | + +### Layer ordering + +Use `[Order]` to control where your adornment renders relative to built-in layers: + +| Layer | Description | +|-------|-------------| +| `PredefinedAdornmentLayers.Selection` | The selection highlight | +| `PredefinedAdornmentLayers.Text` | The actual text glyphs | +| `PredefinedAdornmentLayers.Caret` | The caret | +| `PredefinedAdornmentLayers.CurrentLineHighlighter` | Current line highlight | +| `PredefinedAdornmentLayers.TextMarker` | Text marker decorations | + +### Viewport-relative adornment (watermark example) + +```csharp +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Microsoft.VisualStudio.Text.Editor; + +namespace MyExtension; + +internal sealed class WatermarkAdornment +{ + private const string AdornmentLayerName = "MyWatermarkAdornment"; + private readonly IAdornmentLayer _layer; + private readonly IWpfTextView _view; + private readonly UIElement _watermark; + + public WatermarkAdornment(IWpfTextView view) + { + _view = view; + _layer = view.GetAdornmentLayer(AdornmentLayerName); + + _watermark = new TextBlock + { + Text = "DRAFT", + FontSize = 48, + Foreground = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0x00, 0x00)), + IsHitTestVisible = false, + }; + + _view.ViewportHeightChanged += (s, e) => Render(); + _view.ViewportWidthChanged += (s, e) => Render(); + + Render(); + } + + private void Render() + { + _layer.RemoveAllAdornments(); + + Canvas.SetLeft(_watermark, _view.ViewportRight - 200); + Canvas.SetTop(_watermark, _view.ViewportTop + 10); + + _layer.AddAdornment( + AdornmentPositioningBehavior.ViewportRelative, + null, + null, + _watermark, + null); + } +} +``` + +### Key points + +- Adornments are WPF `UIElement` instances drawn on an `IAdornmentLayer`. +- Always freeze brushes and drawings for performance. +- Subscribe to `LayoutChanged` for text-relative adornments to redraw on scroll/edit. +- Set `IsHitTestVisible = false` on adornments that should not intercept mouse clicks. +- The `[ContentType]` attribute controls which file types activate your adornment (e.g., `"CSharp"`, `"text"`, `"code"`). +- The `[TextViewRole]` attribute limits which editor instances trigger your listener (use `PredefinedTextViewRoles.Document` to avoid triggering in peek, diff, etc.). +- **Remember to add the MEF asset type to your `.vsixmanifest`** — see the top of this document. + +--- + +## Key guidance + +- **VisualStudio.Extensibility** does not support custom editor adornments. +- **VSSDK / Community Toolkit** — Use `IWpfTextViewCreationListener` with MEF exports. Define an `AdornmentLayerDefinition`, subscribe to `LayoutChanged`, and draw WPF elements on the layer. +- Always declare the **MEF component asset type** in `source.extension.vsixmanifest` or the adornment will silently fail to load. +- Use `ContentType` and `TextViewRole` to scope your adornment to the right editors. +- Adornments run on the UI thread — keep drawing logic fast. + +## Troubleshooting + +- **Adornment doesn't appear at all:** Check the MEF asset type in `.vsixmanifest` (the #1 cause). Also verify `[ContentType]` matches the file type you're testing with and `[TextViewRole]` isn't filtering out the editor instance. +- **Adornment appears but in the wrong position:** Verify you're using `textViewLines.GetCharacterBounds()` or `GetTextMarkerGeometry()` to calculate positions. Character positions change on scroll and view resize — recalculate in `LayoutChanged`. +- **Editor becomes sluggish after adding adornment:** Your `LayoutChanged` handler is doing too much work. Pre-compute data on a background thread and only draw from cache during layout. +- **Adornment steals mouse clicks / can't select text underneath:** Set `IsHitTestVisible = false` on decorative (non-interactive) adornment elements. +- **Memory leak — VS memory grows with each file opened:** You're not unsubscribing from text view events. Subscribe to `ITextView.Closed` to remove your handlers. + +## What NOT to do + +> **Do NOT** forget `IsHitTestVisible = false` on decorative adornment elements — without it, they steal mouse clicks and selections from the editor text underneath. + +> **Do NOT** forget to unsubscribe from `ITextView.LayoutChanged` when the view closes — subscribe to `ITextView.Closed` and remove handlers there. Leaked subscriptions cause memory leaks. + +> **Do NOT** forget the `MefComponent` asset type in `.vsixmanifest` — without it, your `IWpfTextViewCreationListener` is silently ignored (#1 cause of "my adornment doesn't show up"). + +> **Do NOT** do expensive work in `LayoutChanged` — it fires on every scroll, resize, and edit. Pre-compute data on a background thread; only read from cache during layout. + +> **Do NOT** attempt to use VisualStudio.Extensibility for editor adornments — it doesn't support them. The VSSDK in-process MEF approach is the only option. + +## See also + +- [vs-editor-classifier](../adding-editor-classifiers/SKILL.md) +- [vs-editor-tagger](../creating-editor-taggers/SKILL.md) +- [vs-editor-margin](../adding-editor-margins/SKILL.md) +- [vs-editor-text-view-listener](../listening-text-view-events/SKILL.md) +- [vs-theming](../theming-extension-ui/SKILL.md) + +## References + +- [Creating an Editor Adornment (VSSDK)](https://learn.microsoft.com/visualstudio/extensibility/walkthrough-creating-a-view-adornment-commands-and-settings) +- [IWpfTextViewCreationListener](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.text.editor.iwpftextviewcreationlistener) +- [AdornmentLayerDefinition](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.text.editor.adornmentlayerdefinition) diff --git a/.github/skills/adding-options-settings/SKILL.md b/.github/skills/adding-options-settings/SKILL.md new file mode 100644 index 0000000..3850a2f --- /dev/null +++ b/.github/skills/adding-options-settings/SKILL.md @@ -0,0 +1,550 @@ +--- +name: adding-options-settings +description: Add custom settings and options pages to Visual Studio extensions. Use when the user asks how to create a Tools > Options page, store extension settings, read or write user preferences, use BaseOptionModel, DialogPage, or the VisualStudio.Extensibility settings API in a Visual Studio IDE extension. Covers VisualStudio.Extensibility (out-of-process), VSIX Community Toolkit (in-process), and legacy VSSDK (in-process) approaches. +--- + +# Adding Settings and Options Pages to Visual Studio Extensions + +Most extensions need user-configurable settings exposed in the **Tools > Options** dialog. Visual Studio provides three different APIs depending on your extensibility model. + +Without a proper settings page, extensions either hard-code behavior (frustrating users who need different defaults) or invent custom storage that doesn't integrate with VS's settings import/export, roaming, or reset. The Tools > Options page is the standard location users check for extension settings, and VS handles persistence, threading, and UI generation for you. + +**When to use this vs. alternatives:** +- User-configurable settings with auto-generated Tools > Options page → **Options/Settings** (this skill) +- Custom font and color items with per-theme defaults → [vs-fonts-and-colors](../registering-fonts-colors/SKILL.md) +- Key bindings and command customization → [vs-commands](../adding-commands/SKILL.md) +- Workspace-level settings for Open Folder mode → [vs-open-folder](../extending-open-folder/SKILL.md) (`.vs/VSWorkspaceSettings.json`) + +## Decision guide + +| Approach | Settings storage | UI | Thread-safe | Lazy load | +|----------|-----------------|-----|-------------|-----------| +| **VisualStudio.Extensibility** | VS settings store | Auto-generated from setting definitions | Yes | Yes | +| **Community Toolkit** (`BaseOptionModel`) | VS settings store / registry | Auto-generated property grid | Yes | Yes — package doesn't need to load | +| **VSSDK** (`DialogPage`) | Registry | Property grid (or custom WPF via `Window` override) | Manual | Only if `AllowsBackgroundLoading = true` | + +--- + +## 1. VisualStudio.Extensibility (out-of-process, recommended) + +Settings are declared as static properties with the `[VisualStudioContribution]` attribute. VS automatically generates a UI in the **Tools > Options** dialog and handles persistence. + +**NuGet package:** `Microsoft.VisualStudio.Extensibility` +**Key namespace:** `Microsoft.VisualStudio.Extensibility` + +> **Note:** As of VS 2022 17.x, the settings API is in preview. Add `#pragma warning disable VSEXTPREVIEW_SETTINGS` to suppress the experimental warning. + +### Step 1: Define settings and categories + +Settings are organized into categories. Place definitions in any class in your extension: + +```csharp +#pragma warning disable VSEXTPREVIEW_SETTINGS + +internal static class SettingDefinitions +{ + [VisualStudioContribution] + internal static SettingCategory General { get; } = new("general", "General") + { + Description = "General extension settings", + GenerateObserverClass = true, + }; + + [VisualStudioContribution] + internal static Setting.Boolean EnableFeature { get; } = new( + "enableFeature", "Enable Feature", General, defaultValue: true) + { + Description = "Enables the main feature of this extension", + }; + + [VisualStudioContribution] + internal static Setting.String OutputPrefix { get; } = new( + "outputPrefix", "Output Prefix", General, defaultValue: "[MyExt]") + { + Description = "Prefix added to output messages", + MaxStringLength = 50, + }; + + [VisualStudioContribution] + internal static Setting.Integer MaxResults { get; } = new( + "maxResults", "Max Results", General, defaultValue: 100) + { + Description = "Maximum number of results to display", + Minimum = 1, + Maximum = 1000, + }; + + [VisualStudioContribution] + internal static Setting.Enum Theme { get; } = new( + "theme", "Theme", General, defaultValue: "dark", + new("dark", "Dark"), + new("light", "Light"), + new("system", "System Default")); +} +``` + +### Setting types + +| Type | Use case | +|------|----------| +| `Setting.Boolean` | True/false toggle | +| `Setting.String` | Free-text input | +| `Setting.Integer` | Whole number | +| `Setting.Decimal` | Floating point | +| `Setting.Enum` | Dropdown from predefined choices | +| `Setting.FormattedString` | Dates, times, IP addresses, emails, URIs, file/directory paths | +| `Setting.StringArray` | List of strings | +| `Setting.EnumArray` | Multi-select from predefined choices | +| `Setting.ObjectArray` | Sequence of items with multiple typed properties | + +### Step 2: Nested categories + +```csharp +[VisualStudioContribution] +internal static SettingCategory Advanced { get; } = new("advanced", "Advanced", General); + +[VisualStudioContribution] +internal static Setting.Boolean DebugMode { get; } = new( + "debugMode", "Debug Mode", Advanced, defaultValue: false); +``` + +### Step 3: Register the settings observer for dependency injection + +In your `Extension` class, register the observer so it can be injected: + +```csharp +[VisualStudioContribution] +internal class MyExtension : Extension +{ + public override ExtensionConfiguration ExtensionConfiguration => new() + { + Metadata = new("MyExtension", ExtensionAssemblyVersion, "Publisher", + "Description of my extension"), + }; + + protected override void InitializeServices(IServiceCollection serviceCollection) + { + serviceCollection.AddSettingsObservers(); + base.InitializeServices(serviceCollection); + } +} +``` + +### Step 4: Read settings from a command + +**One-shot read (no observer needed):** + +```csharp +public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct) +{ + var result = await Extensibility.Settings().ReadEffectiveValueAsync( + SettingDefinitions.EnableFeature, ct); + bool isEnabled = result.ValueOrDefault(defaultValue: true); + + // Read multiple settings at once + var results = await Extensibility.Settings().ReadEffectiveValuesAsync( + [SettingDefinitions.EnableFeature, SettingDefinitions.MaxResults], ct); + int maxResults = results.ValueOrDefault(SettingDefinitions.MaxResults, defaultValue: 100); +} +``` + +**Using the generated observer (for continuous monitoring):** + +```csharp +public class MyCommand : Command +{ + private readonly Settings.GeneralObserver _settingsObserver; + + public MyCommand(VisualStudioExtensibility extensibility, Settings.GeneralObserver settingsObserver) + : base(extensibility) + { + _settingsObserver = settingsObserver; + } + + public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct) + { + var snapshot = await _settingsObserver.GetSnapshotAsync(ct); + bool isEnabled = snapshot.EnableFeature.ValueOrDefault(defaultValue: true); + string prefix = snapshot.OutputPrefix.ValueOrDefault(defaultValue: "[MyExt]"); + } +} +``` + +### Step 5: React to setting changes + +```csharp +public MyToolWindow(Settings.GeneralObserver settingsObserver) +{ + // The Changed handler fires at least once with the current values + settingsObserver.Changed += OnSettingsChanged; +} + +private async Task OnSettingsChanged(Settings.GeneralSnapshot snapshot) +{ + bool isEnabled = snapshot.EnableFeature.ValueOrDefault(defaultValue: true); + // Update UI or behavior +} +``` + +### Step 6: Write settings programmatically + +```csharp +var writeResult = await Extensibility.Settings().WriteAsync( + batch => + { + batch.WriteSetting(SettingDefinitions.EnableFeature, value: false); + batch.WriteSetting(SettingDefinitions.OutputPrefix, value: "[Updated]"); + }, + description: "Reset settings to defaults", + cancellationToken); +``` + +### Step 7: Monitor a single setting without an observer + +```csharp +IDisposable subscription = await Extensibility.Settings().SubscribeAsync( + SettingDefinitions.EnableFeature, + cancellationToken, + changeHandler: result => + { + bool isEnabled = result.ValueOrDefault(defaultValue: true); + // React to the change + }); + +// Dispose to stop monitoring +subscription.Dispose(); +``` + +### Localization + +Use string resource references for display names and descriptions: + +```csharp +[VisualStudioContribution] +internal static SettingCategory General { get; } = new("general", "%MyExtension.Settings.General%") +{ + Description = "%MyExtension.Settings.General.Description%", + GenerateObserverClass = true, +}; +``` + +--- + +## 2. VSIX Community Toolkit (in-process) + +The Community Toolkit provides `BaseOptionModel` — a thread-safe, lazy-loading options model. Settings are simple C# properties. The Toolkit handles persistence to the VS settings store and exposes the settings in the **Tools > Options** dialog via a property grid. + +**NuGet package:** `Community.VisualStudio.Toolkit` +**Key namespace:** `Community.VisualStudio.Toolkit` + +### Step 1: Create the options model + +Create an `Options/` folder and add your settings class: + +**Options/General.cs:** + +```csharp +using System.ComponentModel; +using Community.VisualStudio.Toolkit; + +internal partial class OptionsProvider +{ + // Register the options with these attributes on your package class: + // [ProvideOptionPage(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] + // [ProvideProfile(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] + public class GeneralOptions : BaseOptionPage { } +} + +public class General : BaseOptionModel +{ + [Category("Features")] + [DisplayName("Enable Feature")] + [Description("Enables the main feature of this extension.")] + [DefaultValue(true)] + public bool EnableFeature { get; set; } = true; + + [Category("Features")] + [DisplayName("Max Results")] + [Description("Maximum number of results to display.")] + [DefaultValue(100)] + public int MaxResults { get; set; } = 100; + + [Category("Output")] + [DisplayName("Output Prefix")] + [Description("Prefix added to output messages.")] + [DefaultValue("[MyExt]")] + public string OutputPrefix { get; set; } = "[MyExt]"; + + [Category("Appearance")] + [DisplayName("Theme")] + [Description("Select the color theme for the extension UI.")] + [DefaultValue(ThemeOption.Dark)] + [TypeConverter(typeof(EnumConverter))] + public ThemeOption Theme { get; set; } = ThemeOption.Dark; +} + +public enum ThemeOption +{ + Dark, + Light, + System, +} +``` + +### Step 2: Register in the package + +```csharp +[ProvideOptionPage(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] +[ProvideProfile(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] +public sealed class MyExtensionPackage : ToolkitPackage +{ + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) + { + await this.RegisterCommandsAsync(); + } +} +``` + +- **`ProvideOptionPage`** — makes the page visible in the **Tools > Options** dialog. +- **`ProvideProfile`** — enables roaming and import/export. Optional but recommended. + +### Step 3: Read and write settings + +**Synchronous (e.g., from `BeforeQueryStatus`):** + +```csharp +bool isEnabled = General.Instance.EnableFeature; +int max = General.Instance.MaxResults; + +// Write +General.Instance.MaxResults = 200; +General.Instance.Save(); +``` + +**Asynchronous (recommended):** + +```csharp +protected override async Task ExecuteAsync(OleMenuCmdEventArgs e) +{ + General settings = await General.GetLiveInstanceAsync(); + bool isEnabled = settings.EnableFeature; + string prefix = settings.OutputPrefix; + + // Write + settings.MaxResults = 200; + await settings.SaveAsync(); +} +``` + +### Step 4: React to setting changes + +```csharp +General.Saved += OnSettingsSaved; + +private void OnSettingsSaved(object sender, General e) +{ + bool isEnabled = e.EnableFeature; + // Update behavior based on new settings +} +``` + +### Multiple option pages + +Create additional option classes for separate pages: + +**Options/Advanced.cs:** + +```csharp +internal partial class OptionsProvider +{ + public class AdvancedOptions : BaseOptionPage { } +} + +public class Advanced : BaseOptionModel +{ + [Category("Diagnostics")] + [DisplayName("Debug Mode")] + [Description("Enables verbose logging.")] + [DefaultValue(false)] + public bool DebugMode { get; set; } = false; +} +``` + +Register the additional page: + +```csharp +[ProvideOptionPage(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] +[ProvideProfile(typeof(OptionsProvider.GeneralOptions), "MyExtension", "General", 0, 0, true)] +[ProvideOptionPage(typeof(OptionsProvider.AdvancedOptions), "MyExtension", "Advanced", 0, 0, true)] +[ProvideProfile(typeof(OptionsProvider.AdvancedOptions), "MyExtension", "Advanced", 0, 0, true)] +public sealed class MyExtensionPackage : ToolkitPackage { ... } +``` + +--- + +## 3. VSSDK (in-process, legacy) + +The low-level VSSDK uses `DialogPage` for the property-grid UI, with persistence handled automatically via the VS registry. + +**NuGet package:** `Microsoft.VisualStudio.SDK` +**Key namespaces:** `Microsoft.VisualStudio.Shell`, `System.ComponentModel` + +### Step 1: Create the DialogPage + +```csharp +using System.ComponentModel; +using Microsoft.VisualStudio.Shell; + +public class GeneralOptionsPage : DialogPage +{ + [Category("Features")] + [DisplayName("Enable Feature")] + [Description("Enables the main feature of this extension.")] + public bool EnableFeature { get; set; } = true; + + [Category("Features")] + [DisplayName("Max Results")] + [Description("Maximum number of results to display.")] + public int MaxResults { get; set; } = 100; + + [Category("Output")] + [DisplayName("Output Prefix")] + [Description("Prefix added to output messages.")] + public string OutputPrefix { get; set; } = "[MyExt]"; +} +``` + +### Step 2: Register with the package + +```csharp +[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] +[ProvideOptionPage(typeof(GeneralOptionsPage), "MyExtension", "General", 0, 0, true)] +[Guid("YOUR-PACKAGE-GUID")] +public sealed class MyExtensionPackage : AsyncPackage +{ + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) + { + // Package initialization + } +} +``` + +### Step 3: Read settings from the package + +```csharp +public GeneralOptionsPage GetOptionsPage() +{ + return (GeneralOptionsPage)GetDialogPage(typeof(GeneralOptionsPage)); +} +``` + +From a command handler: + +```csharp +private void Execute(object sender, EventArgs e) +{ + ThreadHelper.ThrowIfNotOnUIThread(); + + var package = (MyExtensionPackage)this._package; + GeneralOptionsPage options = package.GetOptionsPage(); + + bool isEnabled = options.EnableFeature; + int maxResults = options.MaxResults; +} +``` + +### Custom WPF UI (advanced) + +Override the `Window` property to provide a custom UI instead of the default property grid: + +```csharp +using System.Runtime.InteropServices; +using System.Windows; +using Microsoft.VisualStudio.Shell; + +[Guid("00000000-0000-0000-0000-000000000000")] +public class AdvancedOptionsPage : UIElementDialogPage +{ + private AdvancedOptionsControl _control; + + protected override UIElement Child => _control ??= new AdvancedOptionsControl(this); + + public bool DebugMode { get; set; } = false; + + public override void SaveSettingsToStorage() + { + _control?.SaveToPage(); + base.SaveSettingsToStorage(); + } + + public override void LoadSettingsFromStorage() + { + base.LoadSettingsFromStorage(); + _control?.LoadFromPage(); + } +} +``` + +### Reading/Writing the settings store directly (VSSDK) + +For settings that don't need a UI page: + +```csharp +using Microsoft.VisualStudio.Settings; +using Microsoft.VisualStudio.Shell.Settings; + +var settingsManager = new ShellSettingsManager(ServiceProvider.GlobalProvider); +WritableSettingsStore userSettings = settingsManager.GetWritableSettingsStore(SettingsScope.UserSettings); + +// Write +if (!userSettings.CollectionExists("MyExtension")) + userSettings.CreateCollection("MyExtension"); +userSettings.SetBoolean("MyExtension", "EnableFeature", true); +userSettings.SetInt32("MyExtension", "MaxResults", 100); + +// Read +bool isEnabled = userSettings.GetBoolean("MyExtension", "EnableFeature", defaultValue: true); +int maxResults = userSettings.GetInt32("MyExtension", "MaxResults", defaultValue: 100); +``` + +--- + +## Key guidance + +- **New extensions** → use `VisualStudio.Extensibility` settings with `GenerateObserverClass = true` for reactive, type-safe settings. +- **Existing Toolkit extensions** → use `BaseOptionModel`. Simple, thread-safe, lazy-loading. +- **Legacy VSSDK** → use `DialogPage` with `ProvideOptionPage`. Consider `UIElementDialogPage` for custom WPF UI. +- Always provide `[DefaultValue]` attributes (Toolkit/VSSDK) or `defaultValue` parameters (Extensibility) so settings have sensible defaults before the user changes them. +- Never access settings on the UI thread synchronously when an async alternative exists. +- Use `ProvideProfile` (Toolkit/VSSDK) to enable roaming and import/export. + +## Troubleshooting + +- **Options page doesn't appear in Tools > Options:** Verify `[ProvideOptionPage]` is on the package class (Toolkit/VSSDK) with the correct `typeof(OptionsProvider.XxxOptions)`. For Extensibility, ensure the `[VisualStudioContribution]` attribute is present on your settings class. +- **Settings reset on every VS restart:** You're not calling `Save()` / `SaveAsync()` after changing values. For VSSDK `DialogPage`, persistence is automatic but only if you don't override `SaveSettingsToStorage` incorrectly. +- **`BaseOptionModel` properties don't persist:** Ensure properties have `public` getters and setters. Private setters won't be serialized. +- **Custom WPF options page shows blank:** You're using `DialogPage` instead of `UIElementDialogPage`. Override the `Child` property (not `Window`) and return your WPF `UserControl`. +- **Settings changes don't take effect until VS restart:** Subscribe to `General.Saved` (Toolkit) or implement `IProfileManager` (VSSDK) to react immediately when the user clicks OK. + +## What NOT to do + +> **Do NOT** store settings in custom files (JSON, XML) — use the VS settings store, which handles roaming, import/export, and reset automatically. + +> **Do NOT** access `DialogPage` properties from a background thread without `SwitchToMainThreadAsync()` — they're backed by the VS registry which may require the UI thread. + +> **Do NOT** use `Registry.CurrentUser` directly — use `WritableSettingsStore` (VSSDK) or `BaseOptionModel` (Toolkit) to write to the correct VS hive. + +> **Do NOT** forget `[DefaultValue]` attributes — without them, the "Reset" button in Tools > Options won't restore sensible defaults. + +## See also + +- [vs-fonts-and-colors](../registering-fonts-colors/SKILL.md) +- [vs-open-folder](../extending-open-folder/SKILL.md) +- [vs-commands](../adding-commands/SKILL.md) + +## References + +- [VisualStudio.Extensibility settings](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/settings/settings) +- [Custom settings and options (Community Toolkit)](https://learn.microsoft.com/visualstudio/extensibility/vsix/recipes/settings-options) +- [Create an options page (VSSDK)](https://learn.microsoft.com/visualstudio/extensibility/creating-an-options-page) +- [Options and Options Pages](https://learn.microsoft.com/visualstudio/extensibility/internals/options-and-options-pages) diff --git a/.github/skills/handling-async-threading/SKILL.md b/.github/skills/handling-async-threading/SKILL.md new file mode 100644 index 0000000..632faed --- /dev/null +++ b/.github/skills/handling-async-threading/SKILL.md @@ -0,0 +1,702 @@ +--- +name: handling-async-threading +description: Correctly handle async/await patterns, thread switching, and JoinableTaskFactory usage in Visual Studio extensions. Use when the user asks about async/await in VS extensions, switching threads, avoiding deadlocks, using ThreadHelper, JoinableTaskFactory, fire-and-forget patterns, blocking the UI thread, VSTHRD analyzer rules, or any threading-related question in Visual Studio extension development. Covers VisualStudio.Extensibility (out-of-process), VSIX Community Toolkit (in-process), and legacy VSSDK (in-process) approaches. +--- + +# Async/Await and Threading in Visual Studio Extensions + +Visual Studio is a single-threaded apartment (STA) application. Many services and COM objects require the **main (UI) thread**. Incorrect threading causes deadlocks, UI freezes, or crashes. This skill covers the correct, non-blocking patterns for all three extensibility approaches. + +Threading is a cross-cutting concern that affects every part of an extension. The core problem: VS COM objects must be accessed from the UI thread, but blocking that thread freezes the entire IDE. `JoinableTaskFactory` solves the deadlock that occurs when the UI thread synchronously waits for background work that itself needs the UI thread. + +**When this skill applies vs. alternatives:** +- Any extension code that calls `IVs*` COM interfaces or accesses VS services → **this skill** (thread switching) +- Showing progress during long operations → combine with [vs-background-tasks-progress](../showing-background-progress/SKILL.md) +- Error handling in async code (catching `OperationCanceledException`, logging) → combine with [vs-error-handling](../handling-extension-errors/SKILL.md) +- Responding to solution/project load events asynchronously → combine with [vs-solution-events](../handling-solution-events/SKILL.md) + +--- + +## Threading Analyzer — install first + +Always install the **Microsoft.VisualStudio.Threading.Analyzers** NuGet package in every VS extension project. It catches threading mistakes at compile time. It's included with the VSIX Community Toolkit. + +**NuGet package:** `Microsoft.VisualStudio.Threading.Analyzers` + +### Key analyzer rules + +| Rule | Severity | Summary | +|------|----------|---------| +| **VSTHRD001** | Critical | Avoid legacy thread switching (`Dispatcher.Invoke`, `ThreadHelper.Generic.Invoke`). Use `JoinableTaskFactory.SwitchToMainThreadAsync` instead. | +| **VSTHRD002** | Critical | Avoid `.Wait()`, `.Result`, `.GetAwaiter().GetResult()` on tasks. Use `await` or `JoinableTaskFactory.Run`. | +| **VSTHRD003** | Critical | Avoid awaiting "foreign" tasks (tasks created outside the current `JoinableTaskFactory.Run` delegate). Wrap with `JoinableTaskFactory.RunAsync` and store as `JoinableTask`. | +| **VSTHRD004** | Critical | Always `await` the result of `SwitchToMainThreadAsync()`. Never call it without `await`. | +| **VSTHRD010** | Critical | Call `ThreadHelper.ThrowIfNotOnUIThread()` or `SwitchToMainThreadAsync()` before invoking single-threaded types (`IVs*` interfaces). | +| **VSTHRD011** | Critical | Use `AsyncLazy` instead of `Lazy` for lazy async initialization. Pass a `JoinableTaskFactory` to the constructor. | +| **VSTHRD100** | Advisory | Avoid `async void` methods — they crash the process on unhandled exceptions. Return `Task` instead. | +| **VSTHRD101** | Advisory | Avoid unsupported async delegates (e.g., `async void` lambdas). | +| **VSTHRD103** | Advisory | Call async method overloads when inside an async method (e.g., `ReadAsync` instead of `Read`). | +| **VSTHRD104** | Advisory | Expose an async option for public APIs that must do async work. | +| **VSTHRD110** | Advisory | Always observe `Task` results — await them, wrap with `JoinableTaskFactory.RunAsync`, or call `.Forget()`. | +| **VSTHRD200** | Guideline | Use `Async` suffix on async method names. | + +--- + +## 1. VisualStudio.Extensibility (out-of-process, recommended) + +Out-of-process extensions run in a **separate process** from Visual Studio. They communicate with the IDE over RPC. This means: + +- There is **no UI thread** in your extension process — your code runs on thread-pool threads. +- There is **no `ThreadHelper`** or `JoinableTaskFactory` from `Microsoft.VisualStudio.Shell`. +- All SDK APIs are **natively async** (`ExecuteCommandAsync`, `InitializeAsync`, etc.) and return `Task`. +- You do **not** need `SwitchToMainThreadAsync` — the SDK handles thread marshaling for you. + +**NuGet package:** `Microsoft.VisualStudio.Extensibility` + +### Async command execution + +```csharp +using Microsoft.VisualStudio.Extensibility; +using Microsoft.VisualStudio.Extensibility.Commands; + +[VisualStudioContribution] +public class MyCommand : Command +{ + public override CommandConfiguration CommandConfiguration => new("%MyCommand.DisplayName%"); + + public MyCommand(VisualStudioExtensibility extensibility) + : base(extensibility) + { + } + + // This runs on a background thread — no UI thread concerns. + // Always honor the cancellationToken. + public override async Task ExecuteCommandAsync( + IClientContext context, CancellationToken cancellationToken) + { + // Do async work directly — no JTF wrapping needed + var result = await DoExpensiveWorkAsync(cancellationToken); + + // Interact with VS through the Extensibility object (all async, all safe) + await this.Extensibility.Shell().ShowPromptAsync( + $"Result: {result}", + PromptOptions.OK, + cancellationToken); + } +} +``` + +### Background work with cancellation + +```csharp +public override async Task ExecuteCommandAsync( + IClientContext context, CancellationToken cancellationToken) +{ + // Offload CPU-intensive work — already on thread pool, but Task.Run + // keeps the async state machine off the hot path + var data = await Task.Run(() => ComputeData(), cancellationToken); + + // Always check cancellation between steps + cancellationToken.ThrowIfCancellationRequested(); + + await this.Extensibility.Shell().ShowPromptAsync( + data.Summary, PromptOptions.OK, cancellationToken); +} +``` + +### Key points for VisualStudio.Extensibility + +- All APIs are `async Task`-returning — just use normal `async`/`await`. +- Always pass `CancellationToken` through the call chain. +- No `ThreadHelper`, no `JoinableTaskFactory`, no `SwitchToMainThreadAsync`. +- Do **not** use `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()` — these can cause thread-pool starvation. +- Do **not** use `async void` — return `Task` from all async methods. + +--- + +## 2. VSIX Community Toolkit (in-process) + +The Community Toolkit runs in-process and wraps VSSDK. You still need `JoinableTaskFactory` for thread switching, but the toolkit provides convenience helpers. + +**NuGet packages:** `Community.VisualStudio.Toolkit`, `Microsoft.VisualStudio.Threading.Analyzers` +**Key namespaces:** `Community.VisualStudio.Toolkit`, `Microsoft.VisualStudio.Shell`, `Microsoft.VisualStudio.Threading` + +### Async command — basic pattern + +```csharp +using Community.VisualStudio.Toolkit; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +[Command(PackageIds.MyCommand)] +internal sealed class MyCommand : BaseCommand +{ + protected override async Task ExecuteAsync(OleMenuCmdEventArgs e) + { + // You start on the UI thread in command handlers. + // Do quick UI work here. + + // Offload heavy work to background thread + var result = await Task.Run(() => AnalyzeData()); + + // Back on the UI thread after await (SynchronizationContext preserved). + await VS.StatusBar.ShowMessageAsync($"Done: {result}"); + } +} +``` + +### Switching to the UI thread + +```csharp +protected override async Task ExecuteAsync(OleMenuCmdEventArgs e) +{ + // Explicitly switch to background + await TaskScheduler.Default; + + // Do background work + var data = await LoadDataAsync(); + + // Switch back to UI thread to update UI or call IVs* interfaces + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + // Safe to call COM / IVs* objects here + var solution = await VS.Solutions.GetCurrentSolutionAsync(); +} +``` + +### AsyncPackage initialization + +```csharp +public sealed class MyExtensionPackage : ToolkitPackage +{ + protected override async Task InitializeAsync( + CancellationToken cancellationToken, + IProgress progress) + { + // Background thread — do heavy init here + await LoadConfigurationAsync(); + + // Switch to UI thread when registering commands/services + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + await this.RegisterCommandsAsync(); + } +} +``` + +### Fire-and-forget — the correct way + +Never use `async void`. Use `JoinableTaskFactory.RunAsync` with `.FireAndForget()`: + +```csharp +// WRONG — async void will crash on exceptions +async void OnSomethingHappened(object sender, EventArgs e) +{ + await DoWorkAsync(); // If this throws, the process crashes +} + +// CORRECT — fire and forget with error reporting +void OnSomethingHappened(object sender, EventArgs e) +{ + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await DoWorkAsync(); + }); +} + +// CORRECT — for event handler registration +myObj.SomeEvent += (s, e) => + ThreadHelper.JoinableTaskFactory.RunAsync(() => HandleEventAsync(s, e)); + +private async Task HandleEventAsync(object sender, EventArgs e) +{ + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + // Do work +} +``` + +### Calling async code from synchronous methods + +When a synchronous method must call async code, use `JoinableTaskFactory.Run`: + +```csharp +// This blocks the calling thread but avoids deadlocks by pumping messages +void MySyncMethod() +{ + ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var solution = await VS.Solutions.GetCurrentSolutionAsync(); + // ... + }); +} +``` + +### Ensure UI thread before calling COM/IVs* objects (VSTHRD010) + +```csharp +// In a synchronous method — assert you're on the UI thread +void DoUiWork() +{ + ThreadHelper.ThrowIfNotOnUIThread(); + // Safe to use IVs* interfaces +} + +// In an async method — switch to the UI thread +async Task DoUiWorkAsync() +{ + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + // Safe to use IVs* interfaces +} +``` + +--- + +## 3. VSSDK (in-process, legacy) + +With raw VSSDK, you manage threads manually using `JoinableTaskFactory` from `ThreadHelper` or your `AsyncPackage`. + +**NuGet packages:** `Microsoft.VisualStudio.SDK`, `Microsoft.VisualStudio.Threading.Analyzers` +**Key namespaces:** `Microsoft.VisualStudio.Shell`, `Microsoft.VisualStudio.Threading` + +### Switch from UI thread to background thread + +```csharp +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Task = System.Threading.Tasks.Task; + +async Task DoWorkAsync() +{ + // Option 1: Use Task.Run for CPU-bound work + var result = await Task.Run(() => + { + // On a thread-pool thread now + return ExpensiveComputation(); + }); + + // Option 2: Explicitly yield to background via TaskScheduler.Default + await TaskScheduler.Default; + // On a thread-pool thread now + DoSomethingSynchronous(); +} +``` + +### Switch from background thread to UI thread + +```csharp +async Task UpdateUiAsync() +{ + // Switch to UI thread — always await this + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + // Now on the UI thread — safe to touch IVs* and COM objects + IVsSolution solution = (IVsSolution)Package.GetGlobalService(typeof(SVsSolution)); + solution.GetProperty((int)__VSPROPID.VSPROPID_SolutionFileName, out object value); +} +``` + +### Switch from background to UI thread in a synchronous method + +```csharp +void SyncMethodThatNeedsUiThread() +{ + ThreadHelper.JoinableTaskFactory.Run(async delegate + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + // On the UI thread — do work + }); +} +``` + +### Call async code from synchronous code (VSTHRD002) + +```csharp +// WRONG — causes deadlocks +void BadMethod() +{ + var result = SomeOperationAsync().Result; // VSTHRD002 + SomeOperationAsync().Wait(); // VSTHRD002 + SomeOperationAsync().GetAwaiter().GetResult(); // VSTHRD002 +} + +// CORRECT — Use JoinableTaskFactory.Run +void CorrectMethod() +{ + ThreadHelper.JoinableTaskFactory.Run(async delegate + { + var result = await SomeOperationAsync(); + // Use result + }); +} +``` + +### AsyncPackage initialization + +```csharp +using System; +using System.Threading; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] +[Guid("YOUR-PACKAGE-GUID")] +public sealed class MyPackage : AsyncPackage +{ + protected override async Task InitializeAsync( + CancellationToken cancellationToken, + IProgress progress) + { + // This runs on a background thread. + // Do heavy initialization work here. + + // Switch to UI thread only when needed + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + // Register services, commands, etc. that need the UI thread + } +} +``` + +### Fire-and-forget with proper tracking (VSTHRD100, VSTHRD110) + +```csharp +// WRONG — async void crashes on exceptions +async void StartBackgroundWork() // VSTHRD100 +{ + await DoSomethingAsync(); +} + +// WRONG — unobserved task (VSTHRD110) +void StartBackgroundWork() +{ + DoSomethingAsync(); // Warning: result not observed +} + +// CORRECT — fire and forget tracked by JoinableTaskFactory +void StartBackgroundWork() +{ + this.JoinableTaskFactory.RunAsync(async delegate + { + await Task.Yield(); // Get off the caller's callstack + await DoSomethingAsync(); + }); +} +``` + +### Ensure async work completes before package disposal + +```csharp +public sealed class MyPackage : AsyncPackage +{ + protected override async Task InitializeAsync( + CancellationToken cancellationToken, + IProgress progress) + { + // Start long-running work using the package's JoinableTaskFactory. + // AsyncPackage ensures all JoinableTask work completes before shutdown. + JoinableTaskFactory.RunAsync(async delegate + { + await SomeLongRunningWorkAsync(DisposalToken); + }); + } +} +``` + +The `AsyncPackage.DisposalToken` is signaled when the package is being disposed. Always honor it in long-running work. + +### Lazy async initialization with AsyncLazy (VSTHRD011) + +```csharp +using Microsoft.VisualStudio.Threading; + +// WRONG — Lazy> can deadlock (VSTHRD011) +private readonly Lazy> _service = new(CreateServiceAsync); + +// CORRECT — AsyncLazy integrates with JoinableTaskFactory +private readonly AsyncLazy _service; + +public MyPackage() +{ + _service = new AsyncLazy( + CreateServiceAsync, + ThreadHelper.JoinableTaskFactory); +} + +private static async Task CreateServiceAsync() +{ + await TaskScheduler.Default; // Move off UI thread + return new MyService(); +} + +// Usage in async code: +async Task UseServiceAsync() +{ + var svc = await _service.GetValueAsync(); + svc.DoWork(); +} +``` + +### Cancellation with SwitchToMainThreadAsync + +```csharp +async Task DoWorkAsync(CancellationToken cancellationToken) +{ + // Pass cancellationToken to cancel the switch if the operation + // is no longer needed — avoids unnecessary UI thread transitions + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + // If you need to guarantee the next code doesn't run after cancellation: + cancellationToken.ThrowIfCancellationRequested(); + + // Do UI-thread work +} +``` + +### StartOnIdle — run UI work without blocking user input + +Use `ThreadHelper.JoinableTaskFactory.StartOnIdle` when you need the **UI thread** but the work is **low priority** and should not delay typing, scrolling, or other user interactions. It schedules your delegate to run on the UI thread only when it is **idle** (no pending user input). + +**When to use StartOnIdle:** + +- Deferred UI initialization after package load (e.g., populating tool window content, setting up status bar) +- Background-triggered UI updates that aren't time-critical (e.g., refreshing decorations, updating a panel after a build) +- Any work that must touch UI-thread-affinitized objects but can wait until the user isn't actively doing something + +**When NOT to use StartOnIdle:** + +- Work that must complete before returning to the caller — use `SwitchToMainThreadAsync()` or `JoinableTaskFactory.Run` instead +- CPU-bound work — move that to a background thread with `Task.Run` or `await TaskScheduler.Default`, then switch to UI only for the final update +- Work that needs to happen immediately in response to a user action (e.g., command execution) — use `SwitchToMainThreadAsync()` directly + +```csharp +// Deferred UI initialization — runs on UI thread when idle +await ThreadHelper.JoinableTaskFactory.StartOnIdle(async delegate +{ + for (int i = 0; i < items.Count; i++) + { + UpdateUIForItem(items[i]); + + // Yield frequently so user input is never delayed. + // Each iteration will wait for the next idle slot. + await Task.Yield(); + } +}); +``` + +**Batching work with StartOnIdle and yielding:** + +When processing many items on the UI thread, call `await Task.Yield()` between batches. Each yield returns control to the VS message pump, and `StartOnIdle` resumes your delegate only when the UI thread is idle again. This keeps the IDE responsive. + +```csharp +// Update a large list of decorations without blocking the editor +_ = ThreadHelper.JoinableTaskFactory.StartOnIdle(async delegate +{ + foreach (var decoration in pendingDecorations) + { + ApplyDecoration(decoration); // UI-thread work + await Task.Yield(); // let VS handle any pending user input + } +}); +``` + +**StartOnIdle returns a `JoinableTask`** — you can `await` it if you need to know when the idle work finishes, or discard it with `_` for true fire-and-forget. + +```csharp +// Wait for idle work to complete before proceeding +JoinableTask idleWork = ThreadHelper.JoinableTaskFactory.StartOnIdle(async delegate +{ + await InitializeToolWindowContentAsync(); +}); + +// Later, if you need the result before continuing: +await idleWork; +``` + +**Specifying priority with `WithPriority`:** + +For finer control over scheduling priority beyond idle, combine `WithPriority` with `RunAsync`: + +```csharp +using System.Windows.Threading; + +// Schedule work at DataBind priority (lower than user input, higher than idle) +var lowPriorityJtf = ThreadHelper.JoinableTaskFactory + .WithPriority(Dispatcher.CurrentDispatcher, DispatcherPriority.DataBind); + +await lowPriorityJtf.RunAsync(async delegate +{ + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + RefreshTreeView(); +}); +``` + +### Wait dialog for long blocking work + +```csharp +ThreadHelper.JoinableTaskFactory.Run( + "Processing data...", + async (progress, cancellationToken) => + { + for (int i = 0; i < items.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + await ProcessItemAsync(items[i]); + progress.Report(new ThreadedWaitDialogProgressData( + $"Processing item {i + 1} of {items.Count}", + isCancelable: true)); + } + }); +``` + +--- + +## ConfigureAwait(false) — do NOT use in VS extensions + +For Visual Studio extension code, **do not use `.ConfigureAwait(false)`**. Unlike general .NET library guidance: + +- `JoinableTaskFactory.Run` sets a special `SynchronizationContext` that routes continuations back to the blocked thread without deadlocking. `.ConfigureAwait(false)` defeats this optimization. +- `.ConfigureAwait(false)` causes continuations to run on thread-pool threads, consuming extra threads while the original thread blocks — leading to **thread-pool starvation**. +- Instead of `.ConfigureAwait(false)`, explicitly switch threads when you need background execution: + +```csharp +// WRONG for VS extensions +var data = await LoadDataAsync().ConfigureAwait(false); + +// CORRECT — explicit thread switch +await TaskScheduler.Default; // switch to background +var data = await LoadDataAsync(); +``` + +--- + +## Common anti-patterns and fixes + +### Anti-pattern: Blocking on async from UI thread + +```csharp +// DEADLOCK — UI thread blocks, async work can't get back on UI thread +void OnButtonClick() +{ + var result = GetDataAsync().Result; // Deadlock! +} + +// FIX — Use JoinableTaskFactory.Run +void OnButtonClick() +{ + var result = ThreadHelper.JoinableTaskFactory.Run(async () => + { + return await GetDataAsync(); + }); +} +``` + +### Anti-pattern: async void event handler + +```csharp +// CRASH RISK — unhandled exceptions in async void terminate the process +async void OnWindowLoaded(object sender, EventArgs e) +{ + await InitializeAsync(); // If this throws, VS crashes +} + +// FIX — wrap with JoinableTaskFactory.RunAsync +void OnWindowLoaded(object sender, EventArgs e) +{ + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await InitializeAsync(); + }); +} +``` + +### Anti-pattern: Legacy thread switching + +```csharp +// WRONG — VSTHRD001 +ThreadHelper.Generic.Invoke(() => UpdateUI()); +Dispatcher.CurrentDispatcher.BeginInvoke(() => UpdateUI()); + +// FIX +await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); +UpdateUI(); +``` + +### Anti-pattern: Missing ThrowIfNotOnUIThread + +```csharp +// WRONG — calling IVs* method without thread check (VSTHRD010) +void UseVsService() +{ + IVsSolution sln = GetSolution(); + sln.SetProperty(/*...*/); // May fail or corrupt state if not on UI thread +} + +// FIX +void UseVsService() +{ + ThreadHelper.ThrowIfNotOnUIThread(); + IVsSolution sln = GetSolution(); + sln.SetProperty(/*...*/); +} +``` + +--- + +## Quick reference + +| Scenario | Pattern | +|----------|---------| +| Switch to UI thread (async) | `await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();` | +| Switch to background (async) | `await TaskScheduler.Default;` or `await Task.Run(...)` | +| Assert on UI thread (sync) | `ThreadHelper.ThrowIfNotOnUIThread();` | +| Call async from sync | `ThreadHelper.JoinableTaskFactory.Run(async () => await ...);` | +| Fire-and-forget | `_ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => ...);` | +| Event handler (async) | Wrap body with `JoinableTaskFactory.RunAsync`, avoid `async void` | +| Lazy async init | `new AsyncLazy(factory, ThreadHelper.JoinableTaskFactory)` | +| Honor cancellation | Pass `CancellationToken` through; use `DisposalToken` in packages | +| Long blocking with dialog | `ThreadHelper.JoinableTaskFactory.Run("title", async (progress, ct) => ...)` | +| Deferred UI work at idle | `await ThreadHelper.JoinableTaskFactory.StartOnIdle(async delegate { ... });` | + +## Troubleshooting + +- **IDE freezes (hangs) when running extension code:** You're blocking the UI thread with `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()`. Replace with `await` or wrap in `JoinableTaskFactory.Run`. Run the VSTHRD analyzers to find all instances. +- **Deadlock on `SwitchToMainThreadAsync`:** The UI thread is blocked by a synchronous `.Result`/`.Wait()` call further up the call stack, preventing `SwitchToMainThreadAsync` from completing. Audit the call chain for any synchronous blocking. Use `JoinableTaskFactory.Run` as the outermost sync-over-async bridge. +- **`InvalidCastException` or `RPC_E_WRONG_THREAD` when calling IVs* services:** You're calling STA COM objects from a background thread. Add `await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync()` before the call, or add `ThreadHelper.ThrowIfNotOnUIThread()` to catch it early in debug. +- **VS crashes with no clear stack trace:** An `async void` method threw an unhandled exception. Search for `async void` in your codebase and convert to `async Task` with `JoinableTaskFactory.RunAsync` for fire-and-forget scenarios. +- **VSTHRD analyzers show warnings but extension seems to work:** The threading issues are intermittent — they may only deadlock under load or on slower machines. Fix all VSTHRD warnings; they represent real bugs that will eventually surface in production. +- **`ConfigureAwait(false)` causes intermittent failures:** In VS extension code, `ConfigureAwait(false)` bypasses the `JoinableTaskFactory` synchronization context. Remove it and use explicit thread switching (`await TaskScheduler.Default`) instead. + +## What NOT to do + +> **Do NOT** use `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()` on tasks — causes deadlocks on the UI thread. Use `await` or `JoinableTaskFactory.Run`. (VSTHRD002 catches this.) + +> **Do NOT** use `async void` methods or lambdas — unhandled exceptions crash the entire VS process. Return `Task` and use `JoinableTaskFactory.RunAsync` for fire-and-forget. + +> **Do NOT** use `ConfigureAwait(false)` in VS extension code — bypasses `JoinableTaskFactory`'s `SynchronizationContext`, causing hard-to-debug threading issues. + +> **Do NOT** use `Thread.Sleep()` — blocks the current thread completely. Use `await Task.Delay()` instead. + +> **Do NOT** use `Task.Run()` to wrap calls to `IVs*` COM interfaces — most are STA and must be called from the UI thread. Calling from a thread-pool thread causes `RPC_E_WRONG_THREAD`. + +> **Do NOT** use `Dispatcher.Invoke`/`BeginInvoke` or `ThreadHelper.Generic.Invoke` to switch threads — use `await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync()`. (VSTHRD001 catches this.) + +> **Do NOT** call `SwitchToMainThreadAsync()` without `await` — the call does nothing until awaited. (VSTHRD004 catches this.) + +> **Do NOT** use `Lazy` for async initialization — use `AsyncLazy` with `JoinableTaskFactory`. `Lazy` can deadlock because its lock doesn't yield. (VSTHRD011 catches this.) + +## See also + +- [vs-error-handling](../handling-extension-errors/SKILL.md) +- [vs-background-tasks-progress](../showing-background-progress/SKILL.md) +- [vs-commands](../adding-commands/SKILL.md) +- [vs-tool-window](../adding-tool-windows/SKILL.md) +- [vs-solution-events](../handling-solution-events/SKILL.md) + +## References + +- [Managing Multiple Threads in Managed Code](https://learn.microsoft.com/visualstudio/extensibility/managing-multiple-threads-in-managed-code) +- [Cookbook for Visual Studio Threading](https://github.com/microsoft/vs-threading/blob/main/docfx/docs/cookbook_vs.md) +- [Threading Analyzer Rules (VSTHRD)](https://github.com/microsoft/vs-threading/blob/main/docfx/analyzers/index.md) +- [Microsoft.VisualStudio.Threading.Analyzers NuGet](https://www.nuget.org/packages/Microsoft.VisualStudio.Threading.Analyzers) +- [JoinableTaskFactory API](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.threading.joinabletaskfactory) +- [VisualStudio.Extensibility Commands](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/command/command) diff --git a/.github/skills/handling-extension-errors/SKILL.md b/.github/skills/handling-extension-errors/SKILL.md new file mode 100644 index 0000000..ed87503 --- /dev/null +++ b/.github/skills/handling-extension-errors/SKILL.md @@ -0,0 +1,374 @@ +--- +name: handling-extension-errors +description: Handle errors and exceptions in Visual Studio extensions. Use when the user asks about try/catch patterns, logging exceptions, Output Window logging, ActivityLog, TraceSource diagnostics, telemetry, or notifying users about errors in a Visual Studio IDE extension. Covers the VisualStudio.Extensibility (out-of-process) model, VSSDK Community Toolkit, and legacy VSSDK patterns. +--- + +# Error and Exception Handling in Visual Studio Extensions + +Exceptions will occur in any extension. Handle them in a way that: +1. Captures enough detail for you to diagnose and fix the issue. +2. Informs the user at an appropriate level of severity. +3. Never crashes Visual Studio. + +Unhandled exceptions in an in-process extension can crash the entire IDE, not just your feature. Even in the out-of-process model, unhandled exceptions terminate your extension host process, breaking all functionality until VS restarts it. Proper error handling also means choosing the right notification channel — a modal error dialog for a recoverable background issue trains users to ignore your extension, while silently swallowing errors makes bugs invisible. The patterns here ensure errors are always logged (for you) and surfaced at the right severity (for the user). + +**When to use this vs. alternatives:** +- Logging + notifying the user about errors → **this skill** +- Avoiding threading mistakes that cause crashes → [vs-async-threading](../handling-async-threading/SKILL.md) +- Adding items to the VS Error List (build errors, warnings) → [vs-error-list](../integrating-error-list/SKILL.md) +- Showing a non-blocking info bar notification → [vs-info-bar](../showing-info-bars/SKILL.md) +- Showing a message box for high-severity errors → [vs-message-box](../showing-message-boxes/SKILL.md) + +--- + +## Strategy: pick the right response by severity + +| Severity | Action | +|----------|--------| +| **Low** — user flow unaffected | Log to Output Window or TraceSource. Optionally show a status bar message. | +| **Medium** — user might want to know | Log + show an info bar in the relevant tool/document window. | +| **High** — user flow is interrupted | Log + show a message box / user prompt so the user can acknowledge or retry. | + +Always log the exception regardless of severity. + +--- + +## 1. Include debug symbols + +Before anything else, ensure your `.pdb` files ship with the VSIX. In project properties, set: + +> **Include Debug Symbols in VSIX Container** = `True` + +This gives you accurate stack traces in bug reports. + +--- + +## 2. Logging exceptions + +### a) VSSDK Community Toolkit — Output Window + +The simplest approach. The `Log()` / `LogAsync()` extension methods on `Exception` write the full stack trace to a dedicated Output Window pane. + +**NuGet package:** `Community.VisualStudio.Toolkit` +**Key namespace:** `Community.VisualStudio.Toolkit` + +**Synchronous context:** + +```csharp +try +{ + // Do work +} +catch (Exception ex) +{ + ex.Log(); +} +``` + +**Asynchronous context:** + +```csharp +try +{ + // Do work +} +catch (Exception ex) +{ + await ex.LogAsync(); +} +``` + +### b) VisualStudio.Extensibility — TraceSource + +Each extension part can inject a `TraceSource` instance provided by the extensibility framework. Use it for structured diagnostics: + +**NuGet package:** `Microsoft.VisualStudio.Extensibility` +**Key namespace:** `Microsoft.VisualStudio.Extensibility` + +```csharp +using System.Diagnostics; + +public class MyCommand : Command +{ + private readonly TraceSource _logger; + + public MyCommand(VisualStudioExtensibility extensibility, TraceSource traceSource) + : base(extensibility) + { + _logger = traceSource; + } + + public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct) + { + try + { + // Do work + } + catch (Exception ex) + { + _logger.TraceEvent(TraceEventType.Error, 0, ex.ToString()); + } + } +} +``` + +Logs are written to `%TEMP%\VSLogs` in `.svclog` XML format and can be viewed with [Microsoft Service Trace Viewer](https://learn.microsoft.com/dotnet/framework/wcf/service-trace-viewer-tool-svctraceviewer-exe). + +### c) Custom Output Window pane + +For VSIX Community Toolkit extensions, you can create your own Output Window pane for logging: + +```csharp +OutputWindowPane pane = await VS.Windows.CreateOutputWindowPaneAsync("My Extension"); +await pane.WriteLineAsync($"Error: {ex.Message}"); +await pane.WriteLineAsync(ex.StackTrace); +``` + +### d) Activity log (VSSDK) + +**NuGet package:** `Microsoft.VisualStudio.SDK` +**Key namespaces:** `Microsoft.VisualStudio.Shell`, `Microsoft.VisualStudio.Shell.Interop` + +```csharp +IVsActivityLog log = await GetServiceAsync(typeof(SVsActivityLog)) as IVsActivityLog; +log?.LogEntry( + (uint)__ACTIVITYLOG_ENTRYTYPE.ALE_ERROR, + this.ToString(), + $"An error occurred: {ex.Message}"); +``` + +The activity log writes to `%APPDATA%\Microsoft\VisualStudio\\ActivityLog.xml`. + +--- + +## 3. Notifying the user about errors + +### Low severity — Status bar + +For Vsix Community Toolkit extensions, use: + +```csharp +await VS.StatusBar.ShowMessageAsync("An error occurred. See the Output Window for details."); +``` + +**VSSDK:** + +```csharp +IVsStatusbar statusBar = (IVsStatusbar)ServiceProvider.GetService(typeof(SVsStatusbar)); +statusBar?.SetText("An error occurred. See the Output Window for details."); +``` + +### Medium severity — Info bar + +**Community Toolkit:** + +```csharp +var model = new InfoBarModel( + new[] { new InfoBarTextSpan("Something went wrong. "), + new InfoBarHyperlink("View details") }); + +InfoBar infoBar = await VS.InfoBar.CreateAsync(ToolWindowGuids80.SolutionExplorer, model); +await infoBar.TryShowInfoBarUIAsync(); +``` + +> Info bars via raw VSSDK require `IVsInfoBarUIFactory` and `IVsInfoBarHost` — see the [vs-message-box skill](../showing-message-boxes/SKILL.md) for the full VSSDK info bar pattern. + +### High severity — Message box + +**Community Toolkit:** + +```csharp +await VS.MessageBox.ShowAsync( + "Error", + $"An unexpected error occurred: {ex.Message}", + OLEMSGICON.OLEMSGICON_CRITICAL, + OLEMSGBUTTON.OLEMSGBUTTON_OK); +``` + +**VSSDK (`VsShellUtilities.ShowMessageBox`):** + +```csharp +VsShellUtilities.ShowMessageBox( + this.package, // IServiceProvider (your Package instance) + $"An unexpected error occurred: {ex.Message}", // message + "Error", // title + OLEMSGICON.OLEMSGICON_CRITICAL, // icon + OLEMSGBUTTON.OLEMSGBUTTON_OK, // buttons + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); // default button +``` + +> `VsShellUtilities.ShowMessageBox` is the lowest-level VSSDK API and works without the Community Toolkit. The first parameter is any `IServiceProvider` — typically your `AsyncPackage` instance. It returns an `int` matching the `VSConstants.MessageBoxResult` values if you need to branch on the user's choice. + +**VisualStudio.Extensibility:** + +```csharp +await this.Extensibility.Shell().ShowPromptAsync( + $"An unexpected error occurred: {ex.Message}", + PromptOptions.ErrorConfirm, + ct); +``` + +--- + +## 4. Complete error handling pattern + +### VisualStudio.Extensibility (out-of-process) + +```csharp +public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct) +{ + try + { + // Main logic + await DoWorkAsync(ct); + } + catch (OperationCanceledException) + { + // User or system cancelled — no action needed + } + catch (Exception ex) + { + _logger.TraceEvent(TraceEventType.Error, 0, ex.ToString()); + + await this.Extensibility.Shell().ShowPromptAsync( + $"An error occurred: {ex.Message}", + PromptOptions.ErrorConfirm, + ct); + } +} +``` + +### Community Toolkit (in-process) + +```csharp +protected override async Task ExecuteAsync(OleMenuCmdEventArgs e) +{ + try + { + // Main logic + await DoWorkAsync(); + } + catch (OperationCanceledException) + { + // Cancelled — nothing to do + } + catch (Exception ex) + { + await ex.LogAsync(); + + await VS.MessageBox.ShowAsync( + "Error", + $"An error occurred: {ex.Message}", + OLEMSGICON.OLEMSGICON_CRITICAL, + OLEMSGBUTTON.OLEMSGBUTTON_OK); + } +} +``` + +### VSSDK (in-process, legacy) + +```csharp +private void Execute(object sender, EventArgs e) +{ + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + // Main logic + DoWork(); + } + catch (OperationCanceledException) + { + // Cancelled — nothing to do + } + catch (Exception ex) + { + IVsActivityLog log = Package.GetGlobalService(typeof(SVsActivityLog)) as IVsActivityLog; + log?.LogEntry( + (uint)__ACTIVITYLOG_ENTRYTYPE.ALE_ERROR, + nameof(MyCommand), + ex.ToString()); + + VsShellUtilities.ShowMessageBox( + this.package, + $"An error occurred: {ex.Message}", + "Error", + OLEMSGICON.OLEMSGICON_CRITICAL, + OLEMSGBUTTON.OLEMSGBUTTON_OK, + OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } +} +``` + +--- + +## 5. Automated telemetry + +For production extensions, integrate an APM system to catch errors you can't reproduce locally: + +- **Application Insights** — `TelemetryClient.TrackException(ex)` +- Other options: Raygun, Google Analytics, Sentry + +Always mention telemetry collection in your extension's privacy statement. + +--- + +## 6. IGuardedOperations (editor extension points) + +Visual Studio's editor infrastructure uses `IGuardedOperations` to catch exceptions from MEF extension points (classifiers, taggers, etc.) so a broken extension doesn't take down the editor. If you build editor extensions, your code already benefits from this. + +You can also use it explicitly: + +```csharp +[Import] +IGuardedOperations GuardedOperations { get; set; } + +// Wraps the call and swallows + logs exceptions +GuardedOperations.CallExtensionPoint(this, () => { + // Potentially failing work +}); +``` + +--- + +## Key guidance + +- **Always catch `OperationCanceledException` separately** — it's normal control flow, not an error. +- **Never swallow exceptions silently** — at minimum log them. +- **Ship .pdb files** to get meaningful stack traces. +- **Use `TraceSource`** in the new extensibility model; **use `ex.Log()`** in the Community Toolkit. +- **Match notification severity** to the user impact — don't show a modal dialog for a recoverable background error. + +## Troubleshooting + +- **Exceptions are silently swallowed — no log output:** You're catching exceptions without logging them. Every `catch` block should call `ex.Log()` (Toolkit), `_logger.TraceEvent(...)` (Extensibility), or write to the Activity Log (VSSDK) before any user notification. +- **Output Window pane doesn't appear:** The `ex.Log()` Toolkit helper writes to a pane named after your extension. Open the Output Window and check the **Show output from** dropdown — your pane may exist but not be selected. +- **TraceSource logs are empty (Extensibility):** Ensure you're injecting `TraceSource` via the constructor, not creating a new instance manually. The framework-provided `TraceSource` is wired to the VS logging infrastructure; a manually created one writes nowhere. +- **Activity log file is empty or missing:** The Activity Log is only written when VS is launched with `/Log`. For development use `%APPDATA%\Microsoft\VisualStudio\\ActivityLog.xml`. Consider the Output Window or TraceSource for day-to-day logging instead. +- **VS crashes with no exception in your logs:** An `async void` method threw an unhandled exception. Search for `async void` and convert to `async Task`. See [vs-async-threading](../handling-async-threading/SKILL.md). +- **User sees raw stack traces in error dialogs:** You're passing `ex.ToString()` or `ex.StackTrace` to the message box. Log the full exception, but show only `ex.Message` (or a friendlier summary) to the user. + +## What NOT to do + +> **Do NOT** swallow exceptions silently with empty `catch` blocks — at minimum, log to the Output Window, TraceSource, or Activity Log. + +> **Do NOT** show raw exception messages or stack traces in user-facing message boxes — they may contain file paths or internal type names. Log the full exception; show a user-friendly summary. + +> **Do NOT** use `async void` — unhandled exceptions crash the VS process. Return `Task` and use `JoinableTaskFactory.RunAsync` for fire-and-forget. + +> **Do NOT** show modal error dialogs for recoverable background errors or during solution load — use an info bar or status bar message instead. + +> **Do NOT** create a new `TraceSource` manually in VisualStudio.Extensibility extensions — inject the framework-provided one through the constructor. + +## See also + +- [vs-async-threading](../handling-async-threading/SKILL.md) +- [vs-message-box](../showing-message-boxes/SKILL.md) +- [vs-info-bar](../showing-info-bars/SKILL.md) +- [vs-error-list](../integrating-error-list/SKILL.md) + +## References + +- [Error handling recipe (VSSDK Community Toolkit)](https://learn.microsoft.com/visualstudio/extensibility/vsix/recipes/handle-errors) +- [Logging extension diagnostics (VisualStudio.Extensibility)](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/inside-the-sdk/logging) +- [Notifications recipe (VSSDK Community Toolkit)](https://learn.microsoft.com/visualstudio/extensibility/vsix/recipes/notifications) +- [IGuardedOperations](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.utilities.iguardedoperations) diff --git a/.github/skills/handling-solution-events/SKILL.md b/.github/skills/handling-solution-events/SKILL.md new file mode 100644 index 0000000..52b78f5 --- /dev/null +++ b/.github/skills/handling-solution-events/SKILL.md @@ -0,0 +1,515 @@ +--- +name: handling-solution-events +description: React to solution and project lifecycle events in Visual Studio extensions. Use when the user asks how to detect when a solution opens, closes, loads, or unloads, how to listen for project added/removed events, how to use IVsSolutionEvents, Microsoft.VisualStudio.Shell.Events.SolutionEvents, VS.Events.SolutionEvents, or workspace notifications in a Visual Studio IDE extension. Covers VisualStudio.Extensibility (out-of-process), VSIX Community Toolkit (in-process), and legacy VSSDK (in-process) approaches. +--- + +# Reacting to Solution and Project Lifecycle Events in Visual Studio Extensions + +Extensions frequently need to respond when the user opens, closes, or modifies a solution — for instance, to scan files, load caches, or update UI state. Visual Studio provides multiple event APIs depending on your extensibility model. + +Solution events are foundational for extensions that need to initialize state when a workspace becomes available or clean up when it closes. Getting the timing wrong (e.g., enumerating projects before background load completes) leads to missing data and intermittent failures. The API landscape is broad — choosing the right one avoids unnecessary complexity. + +**When to use this vs. alternatives:** +- Detect solution open/close/load to initialize or tear down extension state → **Solution events** (this skill) +- Query projects and files after the solution is loaded → [vs-solution-explorer](../interacting-solution-explorer/SKILL.md) +- React to individual file save/open/close → [vs-file-document-ops](../managing-files-documents/SKILL.md) +- React to build start/end → [vs-build-events](../handling-build-events/SKILL.md) +- Support folder-open (no solution) scenarios → [vs-open-folder](../extending-open-folder/SKILL.md) + +## Decision guide + +| Approach | API | Thread safety | Scope | +|----------|-----|---------------|-------| +| **VisualStudio.Extensibility** | Activation constraints + workspace queries | Fully async, out-of-process | Solution open/close via activation rules | +| **Community Toolkit** | `VS.Events.SolutionEvents` | Main thread events, simple delegates | Solution + project open/close/rename/load/unload | +| **VSSDK — Shell.Events** | `Microsoft.VisualStudio.Shell.Events.SolutionEvents` | Static events, main thread | Full lifecycle: open, close, load, unload, rename, background load complete | +| **VSSDK — IVsSolutionEvents** | `IVsSolution.AdviseSolutionEvents` | COM callback, main thread | Most granular: all solution/project events | + +--- + +## 1. VisualStudio.Extensibility (out-of-process, recommended) + +The out-of-process model doesn't use traditional event subscriptions. Instead, you use **activation constraints** to conditionally activate extension components when a solution is open, and the **Workspace** API to query solution state. + +**NuGet package:** `Microsoft.VisualStudio.Extensibility` +**Key namespace:** `Microsoft.VisualStudio.Extensibility` + +### Activate a command only when a solution is loaded + +```csharp +[VisualStudioContribution] +internal class AnalyzeCommand : Command +{ + public override CommandConfiguration CommandConfiguration => new("Analyze Solution") + { + Placements = [CommandPlacement.KnownPlacements.ToolsMenu], + // Only visible/enabled when a solution is fully loaded + EnabledWhen = ActivationConstraint.SolutionState(SolutionState.FullyLoaded), + }; + + public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken ct) + { + // Safe to access workspace here — solution is guaranteed to be loaded + } +} +``` + +### Activation constraint options for solution state + +| Constraint | Fires when | +|-----------|-----------| +| `SolutionState.Exists` | A solution is open (projects may still be loading) | +| `SolutionState.FullyLoaded` | Solution and all projects are fully loaded | +| `SolutionState.HasSingleProject` | Exactly one project in the solution | +| `SolutionState.HasMultipleProjects` | Two or more projects | +| `SolutionState.Empty` | Solution is open but contains no projects | + +### React to document/file events as a proxy for solution changes + +For more dynamic scenarios, combine `IDocumentEventsListener` (see vs-file-document-ops skill) with workspace queries to detect meaningful changes. + +> **Note:** The VisualStudio.Extensibility model does not yet expose direct solution lifecycle event subscriptions (OnAfterOpenSolution, etc.). If your extension needs fine-grained solution events, use the in-process model or a hybrid (in-proc package + out-of-proc extensibility). + +--- + +## 2. VSIX Community Toolkit (in-process) + +The Community Toolkit provides `VS.Events.SolutionEvents` with simple .NET event delegates. This is the easiest API for reacting to solution lifecycle changes. + +**NuGet package:** `Community.VisualStudio.Toolkit` +**Key namespace:** `Community.VisualStudio.Toolkit` + +### Subscribe to solution events + +```csharp +public sealed class MyExtensionPackage : ToolkitPackage +{ + protected override async Task InitializeAsync( + CancellationToken cancellationToken, IProgress progress) + { + await this.RegisterCommandsAsync(); + + // Solution-level events + VS.Events.SolutionEvents.OnAfterOpenSolution += OnSolutionOpened; + VS.Events.SolutionEvents.OnAfterCloseSolution += OnSolutionClosed; + VS.Events.SolutionEvents.OnBeforeCloseSolution += OnBeforeSolutionClose; + + // Project-level events + VS.Events.SolutionEvents.OnAfterOpenProject += OnProjectOpened; + VS.Events.SolutionEvents.OnBeforeCloseProject += OnProjectClosing; + VS.Events.SolutionEvents.OnAfterRenameProject += OnProjectRenamed; + VS.Events.SolutionEvents.OnAfterLoadProject += OnProjectLoaded; + VS.Events.SolutionEvents.OnBeforeUnloadProject += OnProjectUnloading; + } + + private void OnSolutionOpened(Solution solution) + { + // Solution is open — safe to enumerate projects + } + + private void OnSolutionClosed() + { + // Solution has been closed — clean up caches, state, etc. + } + + private void OnBeforeSolutionClose() + { + // Solution is about to close — save extension state + } + + private void OnProjectOpened(Project project) + { + // A project was opened/added to the solution + string name = project?.Name; + } + + private void OnProjectClosing(Project project) + { + // A project is about to be removed/closed + } + + private void OnProjectRenamed(Project project) + { + // A project was renamed + } + + private void OnProjectLoaded(Project project) + { + // A previously unloaded project was loaded + } + + private void OnProjectUnloading(Project project) + { + // A project is about to be unloaded (not removed from solution) + } +} +``` + +### Check solution state + +```csharp +// Is a solution currently open? +bool isOpen = await VS.Solutions.IsOpenAsync(); + +// Is a solution in the process of opening? +bool isOpening = await VS.Solutions.IsOpeningAsync(); + +// Get all projects in the current solution +var projects = await VS.Solutions.GetAllProjectsAsync(); +``` + +### Get all projects + +```csharp +var projects = await VS.Solutions.GetAllProjectsAsync(); +foreach (Project project in projects) +{ + string name = project.Name; + string path = project.FullPath; +} +``` + +--- + +## 3a. VSSDK — Microsoft.VisualStudio.Shell.Events.SolutionEvents (recommended VSSDK approach) + +`Microsoft.VisualStudio.Shell.Events.SolutionEvents` is a managed-friendly wrapper around the raw COM `IVsSolutionEvents` interfaces. It provides static events, which are simpler to use than implementing `IVsSolutionEvents` yourself. It wraps `IVsSolutionEvents` through `IVsSolutionEvents8` and `IVsSolutionLoadEvents`. + +**NuGet package:** `Microsoft.VisualStudio.Shell.15.0` (part of `Microsoft.VisualStudio.SDK`) +**Key namespace:** `Microsoft.VisualStudio.Shell.Events` + +### Subscribe to events + +```csharp +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Events; +using SolutionEvents = Microsoft.VisualStudio.Shell.Events.SolutionEvents; + +[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] +[Guid("YOUR-PACKAGE-GUID")] +public sealed class MyExtensionPackage : AsyncPackage +{ + protected override async Task InitializeAsync( + CancellationToken cancellationToken, IProgress progress) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + // Solution-level events + SolutionEvents.OnAfterOpenSolution += OnAfterOpenSolution; + SolutionEvents.OnBeforeCloseSolution += OnBeforeCloseSolution; + SolutionEvents.OnAfterCloseSolution += OnAfterCloseSolution; + + // Background solution load events + SolutionEvents.OnAfterBackgroundSolutionLoadComplete += OnFullyLoaded; + SolutionEvents.OnBeforeBackgroundSolutionLoadBegins += OnBackgroundLoadStarting; + + // Project-level events + SolutionEvents.OnAfterOpenProject += OnAfterOpenProject; + SolutionEvents.OnBeforeCloseProject += OnBeforeCloseProject; + SolutionEvents.OnAfterLoadProject += OnAfterLoadProject; + SolutionEvents.OnBeforeUnloadProject += OnBeforeUnloadProject; + SolutionEvents.OnAfterRenameProject += OnAfterRenameProject; + + // Folder events (Open Folder mode, not solution) + SolutionEvents.OnAfterOpenFolder += OnAfterOpenFolder; + SolutionEvents.OnAfterCloseFolder += OnAfterCloseFolder; + } + + private void OnAfterOpenSolution(object sender, OpenSolutionEventArgs e) + { + // Solution has been opened + // e.IsNewSolution: true if the solution was just created (not loaded from disk) + } + + private void OnBeforeCloseSolution(object sender, EventArgs e) + { + // Solution is about to close — persist any cached state + } + + private void OnAfterCloseSolution(object sender, EventArgs e) + { + // Solution is fully closed — clean up + } + + private void OnFullyLoaded(object sender, EventArgs e) + { + // All projects have finished loading (including background loading) + // This is the safest point to enumerate all projects + } + + private void OnBackgroundLoadStarting(object sender, EventArgs e) + { + // A batch of projects is about to start loading in the background + } + + private void OnAfterOpenProject(object sender, OpenProjectEventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + // e.Hierarchy: IVsHierarchy for the opened project + // e.IsAdded: true if the project was just added (not already in the solution) + } + + private void OnBeforeCloseProject(object sender, CloseProjectEventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + // e.Hierarchy: IVsHierarchy for the project about to close + // e.IsRemoved: true if the project is being removed from the solution + } + + private void OnAfterLoadProject(object sender, LoadProjectEventArgs e) + { + // A previously unloaded project was loaded + } + + private void OnBeforeUnloadProject(object sender, UnloadProjectEventArgs e) + { + // A project is about to be unloaded (still in solution, just not loaded) + } + + private void OnAfterRenameProject(object sender, HierarchyEventArgs e) + { + // A project was renamed + } + + private void OnAfterOpenFolder(object sender, FolderEventArgs e) + { + // A folder was opened (Open Folder mode, not a .sln) + string folderPath = e.FolderPath; + } + + private void OnAfterCloseFolder(object sender, FolderEventArgs e) + { + // A folder was closed + } +} +``` + +### Available events on SolutionEvents + +| Event | When it fires | +|-------|---------------| +| `OnBeforeOpenSolution` | Before the solution file is opened | +| `OnAfterOpenSolution` | After the solution is opened (projects may still be loading) | +| `OnBeforeCloseSolution` | Before the solution begins closing | +| `OnAfterCloseSolution` | After the solution is fully closed | +| `OnAfterBackgroundSolutionLoadComplete` | After all projects finish loading (including background) | +| `OnBeforeBackgroundSolutionLoadBegins` | Before background project loading starts | +| `OnAfterOpenProject` | After a project is opened or added | +| `OnBeforeCloseProject` | Before a project is removed or closed | +| `OnAfterLoadProject` | After a previously unloaded project is loaded | +| `OnBeforeUnloadProject` | Before a project is unloaded | +| `OnAfterRenameProject` | After a project is renamed | +| `OnAfterRenameSolution` | After the solution file is renamed | +| `OnAfterMergeSolution` | After another solution is merged in | +| `OnAfterOpenFolder` | After opening a folder (Open Folder mode) | +| `OnAfterCloseFolder` | After closing a folder | +| `OnAfterLoadAllDeferredProjects` | After all deferred/lazy-loaded projects finish loading | +| `OnQueryCloseSolution` | Query — can observe (not cancel from managed code) | +| `OnQueryCloseProject` | Query for a project close | +| `OnQueryUnloadProject` | Query for a project unload | + +--- + +## 3b. VSSDK — IVsSolutionEvents (lowest-level) + +Implement `IVsSolutionEvents` directly for maximum control. This is the most verbose but also the most flexible — you can implement multiple levels (`IVsSolutionEvents` through `IVsSolutionEvents8`). + +**NuGet package:** `Microsoft.VisualStudio.SDK` +**Key namespaces:** `Microsoft.VisualStudio.Shell`, `Microsoft.VisualStudio.Shell.Interop` + +### Implement and register the event sink + +```csharp +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +internal class SolutionEventListener : IVsSolutionEvents, IDisposable +{ + private readonly IVsSolution _solution; + private readonly uint _cookie; + + public SolutionEventListener() + { + ThreadHelper.ThrowIfNotOnUIThread(); + _solution = (IVsSolution)Package.GetGlobalService(typeof(SVsSolution)); + _solution.AdviseSolutionEvents(this, out _cookie); + } + + public void Dispose() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_cookie != 0) + _solution.UnadviseSolutionEvents(_cookie); + } + + public int OnAfterOpenSolution(object pUnkReserved, int fNewSolution) + { + // Solution opened. fNewSolution != 0 means it was just created. + return VSConstants.S_OK; + } + + public int OnAfterCloseSolution(object pUnkReserved) + { + // Solution fully closed + return VSConstants.S_OK; + } + + public int OnAfterOpenProject(IVsHierarchy pHierarchy, int fAdded) + { + // Project opened. fAdded != 0 means it was newly added. + return VSConstants.S_OK; + } + + public int OnBeforeCloseProject(IVsHierarchy pHierarchy, int fRemoved) + { + // Project about to close. fRemoved != 0 means it's being removed. + return VSConstants.S_OK; + } + + public int OnQueryCloseProject(IVsHierarchy pHierarchy, int fRemoving, ref int pfCancel) + { + // Set pfCancel = 1 to prevent the project from closing + return VSConstants.S_OK; + } + + public int OnQueryCloseSolution(object pUnkReserved, ref int pfCancel) + { + // Set pfCancel = 1 to prevent the solution from closing + return VSConstants.S_OK; + } + + // Remaining required interface members: + public int OnBeforeCloseSolution(object pUnkReserved) => VSConstants.S_OK; + public int OnAfterLoadProject(IVsHierarchy pStubHierarchy, IVsHierarchy pRealHierarchy) => VSConstants.S_OK; + public int OnQueryUnloadProject(IVsHierarchy pRealHierarchy, ref int pfCancel) => VSConstants.S_OK; + public int OnBeforeUnloadProject(IVsHierarchy pRealHierarchy, IVsHierarchy pStubHierarchy) => VSConstants.S_OK; +} +``` + +### Initialize the listener from the package + +```csharp +[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] +[Guid("YOUR-PACKAGE-GUID")] +public sealed class MyExtensionPackage : AsyncPackage +{ + private SolutionEventListener _solutionListener; + + protected override async Task InitializeAsync( + CancellationToken cancellationToken, IProgress progress) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + _solutionListener = new SolutionEventListener(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + ThreadHelper.ThrowIfNotOnUIThread(); + _solutionListener?.Dispose(); + } + base.Dispose(disposing); + } +} +``` + +--- + +## Common patterns + +### Run initialization after the solution is fully loaded + +This is a very common need — wait until all projects are available before scanning: + +**Community Toolkit:** + +```csharp +VS.Events.SolutionEvents.OnAfterOpenSolution += async (solution) => +{ + // Solution is open, but projects may still be loading + // If you need all projects loaded, also wait for: + var projects = await VS.Solutions.GetAllProjectsAsync(); + await InitializeExtensionDataAsync(projects); +}; +``` + +**Shell.Events.SolutionEvents:** + +```csharp +SolutionEvents.OnAfterBackgroundSolutionLoadComplete += (sender, e) => +{ + // All projects are now fully loaded — safest point to scan + ThreadHelper.ThrowIfNotOnUIThread(); + ScanAllProjects(); +}; +``` + +### Get project information from IVsHierarchy + +When working with raw `IVsSolutionEvents`, the event provides `IVsHierarchy`. To get useful information: + +```csharp +private void OnAfterOpenProject(IVsHierarchy hierarchy) +{ + ThreadHelper.ThrowIfNotOnUIThread(); + + // Get the project name + hierarchy.GetProperty( + (uint)VSConstants.VSITEMID.Root, + (int)__VSHPROPID.VSHPROPID_Name, + out object nameObj); + string projectName = nameObj as string; + + // Get the project file path + hierarchy.GetCanonicalName((uint)VSConstants.VSITEMID.Root, out string projectPath); +} +``` + +--- + +## Key guidance + +- **New extensions** → use VisualStudio.Extensibility activation constraints (`SolutionState.FullyLoaded`) for command visibility. For fine-grained events, hybrid in-process mode is needed. +- **Existing Toolkit extensions** → use `VS.Events.SolutionEvents` for the simplest event subscription. It covers the most common scenarios. +- **VSSDK — `Microsoft.VisualStudio.Shell.Events.SolutionEvents`** → use for the richest event set with static events. This is the recommended VSSDK approach (simpler than implementing `IVsSolutionEvents` manually). Includes background load completion and folder open/close events. +- **VSSDK — `IVsSolutionEvents`** → implement directly only when you need to **cancel** operations via `OnQueryCloseSolution`/`OnQueryCloseProject`, which is not possible with the static event wrappers. +- Always use `OnAfterBackgroundSolutionLoadComplete` (not `OnAfterOpenSolution`) if you need all projects to be fully loaded before doing work. +- Always unsubscribe from events or call `UnadviseSolutionEvents` when your extension is disposed to prevent memory leaks. +- Solution events fire on the **main thread** — keep handlers fast and offload heavy work to background tasks. + +## What NOT to do + +> **Do NOT** enumerate all projects in `OnAfterOpenSolution` — background-loaded projects may still be loading. Use `OnAfterBackgroundSolutionLoadComplete` instead. + +> **Do NOT** do heavy work in solution event handlers — they run on the UI thread. Offload with `JoinableTaskFactory.RunAsync` or `Task.Run`; capture event data first. + +> **Do NOT** forget to unsubscribe or call `UnadviseSolutionEvents` in `Dispose` — leaked subscriptions cause memory leaks and stale-reference exceptions. + +> **Do NOT** implement `IVsSolutionEvents` directly when you only need basic open/close/rename — use `VS.Events.SolutionEvents` (Toolkit) or `Microsoft.VisualStudio.Shell.Events.SolutionEvents` (VSSDK). Only implement the interface when you need to cancel operations. + +> **Do NOT** use `EnvDTE.Events.SolutionEvents` — requires storing a strong field reference (or COM GC stops events), and the DTE model is deprecated. + +## Troubleshooting + +- **Event handler never fires:** For Toolkit, verify you're subscribing in `InitializeAsync` *after* `await JoinableTaskFactory.SwitchToMainThreadAsync()`. For VSSDK, confirm `AdviseSolutionEvents` returned a non-zero cookie. +- **Projects appear empty when accessed in `OnAfterOpenSolution`:** Background-loaded projects aren't ready yet. Use `OnAfterBackgroundSolutionLoadComplete` to ensure all projects are fully loaded. +- **Events fire multiple times per solution open:** You're subscribing in a code path that runs more than once (e.g., command execution). Move subscription to `InitializeAsync` or a one-time initialization path. +- **Memory leak after repeated solution open/close cycles:** Ensure you call `UnadviseSolutionEvents` in `Dispose()` (VSSDK) or unsubscribe from static events (Toolkit/VSSDK Shell.Events). +- **Open Folder events don't fire:** Solution events don't fire for Open Folder mode. Use `IVsSolutionEvents7.OnAfterOpenFolder` or the Open Folder UI context. + +## See also + +- [vs-solution-explorer](../interacting-solution-explorer/SKILL.md) +- [vs-build-events](../handling-build-events/SKILL.md) +- [vs-file-document-ops](../managing-files-documents/SKILL.md) +- [vs-open-folder](../extending-open-folder/SKILL.md) +- [vs-background-tasks-progress](../showing-background-progress/SKILL.md) + +## References + +- [SolutionEvents class (Microsoft.VisualStudio.Shell.Events)](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.shell.events.solutionevents) +- [IVsSolutionEvents interface](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.shell.interop.ivssolutionevents) +- [Solutions (Community Toolkit tips)](https://learn.microsoft.com/visualstudio/extensibility/vsix/tips/solutions) +- [Activation Constraints (VisualStudio.Extensibility)](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/inside-the-sdk/activation-constraints) diff --git a/.github/skills/listening-text-view-events/SKILL.md b/.github/skills/listening-text-view-events/SKILL.md new file mode 100644 index 0000000..165c878 --- /dev/null +++ b/.github/skills/listening-text-view-events/SKILL.md @@ -0,0 +1,289 @@ +--- +name: listening-text-view-events +description: React to text editor lifecycle events such as open, close, and content changes. Use when the user asks how to detect when an editor opens, listen for file open events, track active editor changes, respond to text changes in the editor, implement IWpfTextViewCreationListener, or use ITextViewOpenClosedListener. Covers VisualStudio.Extensibility (out-of-process), VSIX Community Toolkit (in-process), and legacy VSSDK (in-process) approaches. +--- + +# Text View Listeners in Visual Studio Extensions + +Text view listeners let you react when editors are opened, closed, or when their content changes. This is a common entry point for extensions that need to initialize per-editor state. Common scenarios: + +- Initialize extension features when a specific file type is opened +- Track which documents are currently open +- React to text changes for live analysis or decoration +- Clean up resources when an editor is closed + +Text view listeners are the foundational hook that most editor extensions use as their entry point. Adornments, taggers, classifiers, and margins all need to know when an editor opens — but text view listeners are also useful on their own for per-document state initialization or for extensions that need to react to text changes without producing visual output. The VisualStudio.Extensibility model provides `ITextViewOpenClosedListener` as a simpler, out-of-process alternative. + +**When to use text view listeners vs. alternatives:** +- React to editor open/close for initialization → **text view listener** (this skill) +- Add visual decorations when an editor opens → combine with [vs-editor-adornment](../adding-editor-adornments/SKILL.md) +- React to text changes for tagging → use a tagger's `ITextBuffer.Changed` subscription (see [vs-editor-tagger](../creating-editor-taggers/SKILL.md)) +- React to document save/close events → [vs-file-document-ops](../managing-files-documents/SKILL.md) + +--- + +## MEF Asset Type Requirement + +**Any extension that uses MEF editor exports must declare the MEF asset type in the `.vsixmanifest` file.** Without this, Visual Studio will not discover your MEF components and your listener will not load. + +Add this inside the `` element of `source.extension.vsixmanifest`: + +```xml + +``` + +> **Note:** The VisualStudio.Extensibility approach does NOT require this MEF asset entry — it uses its own discovery mechanism. + +--- + +## 1. VisualStudio.Extensibility (out-of-process, recommended) + +The new model provides `ITextViewOpenClosedListener` and `ITextViewChangedListener` for reacting to editor events **out-of-process**. + +**NuGet package:** `Microsoft.VisualStudio.Extensibility` +**Key namespace:** `Microsoft.VisualStudio.Extensibility.Editor` + +### Listen for editor open/close + +```csharp +using Microsoft.VisualStudio.Extensibility; +using Microsoft.VisualStudio.Extensibility.Editor; + +namespace MyExtension; + +[VisualStudioContribution] +internal sealed class MyTextViewListener + : ExtensionPart, ITextViewOpenClosedListener +{ + public TextViewExtensionConfiguration TextViewExtensionConfiguration => new() + { + AppliesTo = new[] + { + DocumentFilter.FromDocumentType("CSharp"), + }, + }; + + public Task TextViewOpenedAsync(ITextViewSnapshot textView, CancellationToken cancellationToken) + { + // Called when a matching text view is opened + System.Diagnostics.Debug.WriteLine($"Opened: {textView.Document.Uri}"); + return Task.CompletedTask; + } + + public Task TextViewClosedAsync(ITextViewSnapshot textView, CancellationToken cancellationToken) + { + // Called when the text view is closed + System.Diagnostics.Debug.WriteLine($"Closed: {textView.Document.Uri}"); + return Task.CompletedTask; + } +} +``` + +### Listen for text changes + +```csharp +using Microsoft.VisualStudio.Extensibility; +using Microsoft.VisualStudio.Extensibility.Editor; + +namespace MyExtension; + +[VisualStudioContribution] +internal sealed class MyTextChangeListener + : ExtensionPart, ITextViewChangedListener +{ + public TextViewExtensionConfiguration TextViewExtensionConfiguration => new() + { + AppliesTo = new[] + { + DocumentFilter.FromGlobPattern("**/*.cs"), + }, + }; + + public Task TextViewChangedAsync( + TextViewChangedArgs args, + CancellationToken cancellationToken) + { + // React to text changes + foreach (var change in args.AfterTextView.Document.AsTextDocument().Changes) + { + System.Diagnostics.Debug.WriteLine( + $"Changed at position {change.Position}: '{change.OldText}' -> '{change.NewText}'"); + } + + return Task.CompletedTask; + } +} +``` + +### Document filters + +Use `DocumentFilter` to scope which files trigger your listener: + +```csharp +// By document/content type +DocumentFilter.FromDocumentType("CSharp") +DocumentFilter.FromDocumentType("code") + +// By file glob pattern +DocumentFilter.FromGlobPattern("**/*.cs") +DocumentFilter.FromGlobPattern("**/appsettings*.json") +``` + +--- + +## 2. VSIX Community Toolkit (in-process) + +The Community Toolkit does not wrap text view creation listeners — use the standard VSSDK MEF pattern below. + +--- + +## 3. VSSDK (in-process, legacy) + +**NuGet packages:** `Microsoft.VisualStudio.SDK`, `Microsoft.VisualStudio.Editor`, `Microsoft.VisualStudio.Text.UI.Wpf` +**Key namespaces:** `Microsoft.VisualStudio.Text.Editor`, `Microsoft.VisualStudio.Utilities` + +### Listen for editor open with `IWpfTextViewCreationListener` + +```csharp +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; + +namespace MyExtension; + +[Export(typeof(IWpfTextViewCreationListener))] +[ContentType("CSharp")] +[TextViewRole(PredefinedTextViewRoles.Document)] +internal sealed class MyTextViewCreationListener : IWpfTextViewCreationListener +{ + public void TextViewCreated(IWpfTextView textView) + { + // Called when a C# document editor is created + + // Subscribe to text changes + textView.TextBuffer.Changed += OnTextBufferChanged; + + // Subscribe to editor close + textView.Closed += OnTextViewClosed; + } + + private void OnTextBufferChanged(object sender, Microsoft.VisualStudio.Text.TextContentChangedEventArgs e) + { + foreach (var change in e.Changes) + { + System.Diagnostics.Debug.WriteLine( + $"Changed: '{change.OldText}' -> '{change.NewText}'"); + } + } + + private void OnTextViewClosed(object sender, System.EventArgs e) + { + if (sender is IWpfTextView textView) + { + // Clean up event handlers to avoid leaks + textView.TextBuffer.Changed -= OnTextBufferChanged; + textView.Closed -= OnTextViewClosed; + } + } +} +``` + +### Listen for editor open with `ITextViewCreationListener` (non-WPF) + +For scenarios that don't need WPF access: + +```csharp +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; + +namespace MyExtension; + +[Export(typeof(ITextViewCreationListener))] +[ContentType("text")] +[TextViewRole(PredefinedTextViewRoles.Document)] +internal sealed class MyTextViewListener : ITextViewCreationListener +{ + public void TextViewCreated(ITextView textView) + { + // Works for any text view, not just WPF views + } +} +``` + +### Common text view roles + +Use `[TextViewRole]` to filter which editor instances trigger your listener: + +| Role | Description | +|------|-------------| +| `PredefinedTextViewRoles.Document` | Main document editors | +| `PredefinedTextViewRoles.Interactive` | Interactive windows (e.g., C# Interactive) | +| `PredefinedTextViewRoles.Editable` | Any editable view | +| `PredefinedTextViewRoles.PrimaryDocument` | Primary (not secondary/split) document view | +| `PredefinedTextViewRoles.EmbeddedPeekTextView` | Peek Definition views | + +### Getting the file path from a text view + +```csharp +using Microsoft.VisualStudio.Text; + +// Inside TextViewCreated: +if (textView.TextBuffer.Properties.TryGetProperty( + typeof(ITextDocument), out ITextDocument document)) +{ + string filePath = document.FilePath; +} +``` + +### Key points + +- Always unsubscribe from events in the `Closed` handler to prevent memory leaks. +- Use `[ContentType]` to limit which file types trigger your listener. +- Use `[TextViewRole]` to avoid triggering in peek windows, diff views, etc. +- `TextViewCreated` runs on the UI thread — keep it fast. +- Use `textView.TextBuffer.Properties` to store per-view state. +- For WPF-specific operations (adornments, margins), use `IWpfTextViewCreationListener`. +- **Remember to add the MEF asset type to your `.vsixmanifest`** for the VSSDK approach. + +--- + +## Key guidance + +- **VisualStudio.Extensibility** — Use `ITextViewOpenClosedListener` and `ITextViewChangedListener` with `DocumentFilter` for scoping. No MEF asset required. +- **VSSDK / Community Toolkit** — Export `IWpfTextViewCreationListener` or `ITextViewCreationListener` via MEF. Subscribe to `TextBuffer.Changed` and `Closed` events. Unsubscribe on close. +- Always declare the **MEF component asset type** in `source.extension.vsixmanifest` for the VSSDK approach. +- Prefer `PredefinedTextViewRoles.Document` to avoid triggering in non-document editors. + +## Troubleshooting + +- **Listener never fires:** Check the MEF asset type in `.vsixmanifest`. Verify `[ContentType]` and `[TextViewRole]` match. For Extensibility, ensure the `[VisualStudioContribution]` attribute is present. +- **Listener fires for peek, diff, and embedded editors:** You're missing `[TextViewRole(PredefinedTextViewRoles.Document)]`. Without it, the listener fires for all editor instances. +- **Memory leak when opening/closing files:** You're not unsubscribing from text view events in `ITextView.Closed`. Always subscribe to `Closed` early and clean up there. +- **Text changes not detected:** Subscribe to `ITextBuffer.Changed` (VSSDK/Toolkit) or implement `ITextViewChangedListener` (Extensibility) — text view creation alone doesn't track ongoing edits. + +## What NOT to do + +> **Do NOT** do heavy work in the text view creation callback — it runs on the UI thread and blocks the editor from appearing. + +> **Do NOT** forget the `[TextViewRole]` attribute — without it, your listener triggers for every text view instance (peek, diff, embedded), which may not be intended. + +> **Do NOT** forget to unsubscribe from events when the text view closes — leaked subscriptions cause memory leaks and crashes. + +> **Do NOT** forget the `MefComponent` asset type in `.vsixmanifest` for in-process extensions. + +## See also + +- [vs-editor-adornment](../adding-editor-adornments/SKILL.md) +- [vs-editor-tagger](../creating-editor-taggers/SKILL.md) +- [vs-editor-margin](../adding-editor-margins/SKILL.md) +- [vs-file-document-ops](../managing-files-documents/SKILL.md) + +## References + +- [ITextViewOpenClosedListener (VisualStudio.Extensibility)](https://learn.microsoft.com/visualstudio/extensibility/visualstudio.extensibility/editor/editor-concepts) +- [IWpfTextViewCreationListener (VSSDK)](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.text.editor.iwpftextviewcreationlistener) +- [Text View Roles](https://learn.microsoft.com/dotnet/api/microsoft.visualstudio.text.editor.predefinedtextviewroles) From f5bdd0decf3e35f16d65935a86e99162210b28b5 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:07 +0100 Subject: [PATCH 6/9] Link `.claude/skills` to `.github/skills` for Claude Code Git cannot store a Windows junction, so `Directory.Build.targets` creates it when Visual Studio loads the project, and `.gitignore` excludes it. A command-line build skips the target. `Directory.Build.targets` is a solution item. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 +++ Directory.Build.targets | 28 ++++++++++++++++++++++++++++ StringResourceVisualizer.slnx | 1 + 3 files changed, 32 insertions(+) create mode 100644 Directory.Build.targets diff --git a/.gitignore b/.gitignore index 3e759b7..fa722a8 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,6 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ + +# Local junction to .github/skills, created by Directory.Build.targets +/.claude/skills diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..6ab98e7 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,28 @@ + + + + + + <_ClaudeSkillsLink>$(MSBuildThisFileDirectory).claude/skills + <_ClaudeSkillsTarget>$(MSBuildThisFileDirectory).github/skills + + + + + + + + + + diff --git a/StringResourceVisualizer.slnx b/StringResourceVisualizer.slnx index d24ac8f..15b94af 100644 --- a/StringResourceVisualizer.slnx +++ b/StringResourceVisualizer.slnx @@ -2,6 +2,7 @@ + From c09545507b9eb1e9c3ee13c80740b3df2638c50e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:08 +0100 Subject: [PATCH 7/9] Enable the `vs-agent-plugins` plugin for Claude Code `.claude/settings.json` registers the `vs-agent-plugins` marketplace and enables `vs-extensibility-skills`, the whole catalogue of 40 skills. If Claude Code does not offer to install it, `claude plugin install vs-extensibility-skills@vs-agent-plugins --scope project` does. Co-Authored-By: Claude Sonnet 5.5 --- .claude/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f0f3cea --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "vs-agent-plugins": { + "source": { + "source": "github", + "repo": "madskristensen/vs-agent-plugins" + } + } + }, + "enabledPlugins": { + "vs-extensibility-skills@vs-agent-plugins": true + } +} From ef007bce1efb3bc9c2421f07036d04dda74d789b Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:08 +0100 Subject: [PATCH 8/9] Add the setup steps for the Copilot coding agent The job runs on `windows-2022`: the VSIX builds only with the MSBuild of Visual Studio, and the extension targets Visual Studio 2022. The integrated firewall of the agent is not compatible with Windows, so the agent runs without it. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/copilot-setup-steps.yml | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/copilot-setup-steps.yml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..98ccb79 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,36 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + # The extension is a VSIX, which builds only with the MSBuild of Visual Studio, so the agent needs Windows. + # The extension targets Visual Studio 2022 and its CI build runs on `windows-2022`; `windows-latest` currently has Visual Studio 2026. + # The integrated firewall of the Copilot coding agent is not compatible with Windows, so the agent runs without it. + runs-on: windows-2022 + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + contents: read + + # You can define any steps you want, and they will run before the agent starts. + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v3 + + - name: Restore dependencies + run: msbuild StringResourceVisualizer.slnx /v:m /t:Restore /p:Configuration=Debug From 73659c85ba93515ba962eaa3bc4c72b3f28b95b0 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Mon, 26 Oct 2026 01:40:09 +0100 Subject: [PATCH 9/9] Add the MCP servers for AI agents `.mcp.json` lists `GitHub` for Visual Studio and Claude Code, and `Microsoft Learn` for Claude Code only, because Visual Studio bundles that server itself. The GitHub server does not support the OAuth dynamic client registration that Claude Code needs, so `.claude/scripts/github-mcp-headers.ps1` passes it the token that Git Credential Manager already stores. `.mcp.json` is a solution item. Co-Authored-By: Claude Sonnet 5.5 --- .claude/scripts/github-mcp-headers.ps1 | 22 ++++++++++++++++++++++ .mcp.json | 19 +++++++++++++++++++ StringResourceVisualizer.slnx | 1 + 3 files changed, 42 insertions(+) create mode 100644 .claude/scripts/github-mcp-headers.ps1 create mode 100644 .mcp.json diff --git a/.claude/scripts/github-mcp-headers.ps1 b/.claude/scripts/github-mcp-headers.ps1 new file mode 100644 index 0000000..98534f2 --- /dev/null +++ b/.claude/scripts/github-mcp-headers.ps1 @@ -0,0 +1,22 @@ +# Emits GitHub MCP request headers for Claude Code's `headersHelper`. +# Reuses the GitHub OAuth token stored by Git Credential Manager, so no PAT is needed. +# Prints `{}` when no credential is available, so the server stays unauthenticated instead of failing. + +$ErrorActionPreference = 'Stop' +$env:GCM_INTERACTIVE = 'never' +$env:GIT_TERMINAL_PROMPT = '0' + +$token = $null +try { + $credential = "protocol=https`nhost=github.com`n`n" | git credential fill 2>$null + $token = ($credential | Where-Object { $_ -like 'password=*' }) -replace '^password=', '' +} +catch { +} + +if ($token) { + @{ Authorization = "Bearer $token" } | ConvertTo-Json -Compress +} +else { + '{}' +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ae29d6b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,19 @@ +{ + "servers": { + "GitHub": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + }, + "mcpServers": { + "GitHub": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headersHelper": "pwsh -NoProfile -File .claude/scripts/github-mcp-headers.ps1" + }, + "Microsoft Learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + } + } +} diff --git a/StringResourceVisualizer.slnx b/StringResourceVisualizer.slnx index 15b94af..a7275e0 100644 --- a/StringResourceVisualizer.slnx +++ b/StringResourceVisualizer.slnx @@ -1,6 +1,7 @@ +