Create a plugin
This guide shows how to create a plugin for Techie's Quick Launcher. TQL plugins are C# class libraries. You need Visual Studio to build one. Have a look at the Development environment page for how to setup your computer.
Create a new project
Open Visual Studio:
Click Create a new project, search for "class library" and pick the C# version of the Class Library template:
Give your plugin a name, e.g. "TqlNuGetPlugin":
Use .NET 8 as the framework:
Visual Studio now opens the newly created project. We still need to make a few changes to the project file to make sure the plugin is going to work properly with TQL.
Right click on the project and click Edit Project File.
Change the project file to the following:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0-windows</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>12.0</LangVersion> <UseWPF>true</UseWPF> <UseWindowsForms>true</UseWindowsForms> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> </Project>
- This makes the following changes to the project:
- The target framework is .NET 8, Windows specific.
- Use C# 12.
- WPF and Windows Forms is enabled.
- NuGet lock files are enabled.
- This makes the following changes to the project:
Add NuGet dependencies
The entry point for TQL into your plugin is the plugin class. This class needs
to implement the ITqlPlugin
interface from the
TQLApp.Abstractions NuGet
package. TQL publishes two NuGet packages to help you create TQL plugins. The
second is a NuGet package with utility classes. Instead of adding the
abstractions NuGet package directly, you should add the
TQLApp.Utilities NuGet
package instead:
Right click on the project file and click Manage NuGet Packages....
Search for "tqlapp.utilities" and click Install:
Create the plugin class
TQL plugins must implement the ITqlPlugin
interface and must specify the
TqlPluginAttribute
attribute for TQL to pick them up.
Important
This code snippet contains a GUID. More code snippets in this guide will have one. You need to replace these with a newly generated one when using these code snippets. The Insert Guid Visual Studio extension is a useful extension to do this from inside the IDE.
Replace the automatically generated Class1.cs file with a new file called Plugin.cs and paste in the following content:
using Microsoft.Extensions.DependencyInjection; using Tql.Abstractions; namespace TqlNuGetPlugin; [TqlPlugin] public class Plugin : ITqlPlugin { public static readonly Guid PluginId = Guid.Parse("74bc3db4-c951-442b-921c-887921772d64"); public Guid Id => PluginId; public string Title => "NuGet"; public void ConfigureServices(IServiceCollection services) { } public void Initialize(IServiceProvider serviceProvider) { } public IMatch? DeserializeMatch(Guid typeId, string value) { return null; } public IEnumerable<IMatch> GetMatches() { yield break; } public IEnumerable<IConfigurationPage> GetConfigurationPages() { yield break; } }
Setup debugging
To test the plugin, you need to run it using TQL. This guide assumes you have the latest version of TQL installed locally. You need to change your launch profile to use this version of TQL:
Open the Debug | TqlNuGetPlugin Debug Properties menu item:
Remove the automatically generated launch profile and replace it with a new one of type Executable.
Configure the launch profile as follows:
Set the path to the executable to this: "%LOCALAPPDATA%\Programs\TQL\Tql.App.exe". This configures the launch profile to use the locally installed TQL version.
Set the command line arguments to this: "--env NuGetPlugin --sideload .". This creates an isolated environment for your plugin and side loads it from the current working directory. This will be the build folder of your class library.
Rename the launch profile to e.g. Run.
Start your project.
TQL now starts with a clean environment with your plugin installed. If you want
to verify that your plugin is picked up, you can set a break point in the
ConfigureServices
method.
Create a category match
TQL distinguishes roughly two types of matches: categories (or searchable) and runnable matches. Categories are what you search, and runnable matches is what the search returns. We start with adding a category match.
Add a new class called PackagesMatch and paste in the following contents:
using System.Windows.Media; using Tql.Abstractions; namespace TqlNuGetPlugin; internal class PackagesMatch : ISearchableMatch { public string Text => "NuGet Packages"; public ImageSource Icon { get; } public MatchTypeId TypeId { get; } public string SearchHint => "Find NuGet packages"; public Task<IEnumerable<IMatch>> Search( ISearchContext context, string text, CancellationToken cancellationToken ) { throw new NotImplementedException(); } }
This is a base implementation for a searchable match. To let TQL know of the match, we need to return an instance of it form the
GetMatches()
method in the plugin. Update that method the following:public IEnumerable<IMatch> GetMatches() { yield return new PackagesMatch(); }
Start your project.
Type a single space into the search box. This will show your match:
Add an icon
Every match has an icon associated with it. The simplest way to add them is to add icons as an embedded resource to your plugin and load them in a static class.
Tip
TQL supports both bitmap images (like PNGs and JPEGs), and SVG images. If you can find an SVG image, that's the preferred image type to use.
Download the logo from here: https://github.com/NuGet/Media/blob/main/Images/MainLogo/Vector/nuget.svg.
Copy the logo into your project.
Right click on the logo in the solution explorer and click Properties:
Changed the Build Action to Embedded Resource:
This adds the icon to our class library.
Next we'll create a class to load these resources.
Create a new class called Images and paste in the following code:
using System.Windows.Media; using Tql.Utilities; namespace TqlNuGetPlugin; internal static class Images { public static readonly ImageSource NuGetLogo = ImageFactory.FromEmbeddedResource( typeof(Images), "nuget.svg" ); }
Note
This uses a method from the utilities NuGet package to load the embedded resource and turn it into an
ImageSource
.Update the
Icon
property on our match implementation to use this. Change this property to the following:public ImageSource Icon => Images.NuGetLogo;
Start your project.
Type a single space into the search box. This will show your match again, now with an icon
Add a type ID
Every match class in TQL is identified by a type ID. These are used e.g. as stable names in the user's history.
Tip
It's best practice to put type IDs in a separate class.
Create a new class called TypeIds and paste in the following code:
using Tql.Abstractions; namespace TqlNuGetPlugin; internal static class TypeIds { public static readonly MatchTypeId Packages = new MatchTypeId( Guid.Parse("b1c8f27b-8534-4ee7-bcc6-e45fef01e5bc"), Plugin.PluginId ); }
Type IDs are a combination of the ID of the match and the ID of the plugin.
Update the
TypeId
property of the match implementation to use the new class:public MatchTypeId TypeId => TypeIds.Packages;
Create a runnable match
We now have everything we need to start implementing our search functionality, but we can't yet return anything. We need to implement a runnable match for this.
Important
It's best practice to define DTO objects for all matches. DTO
object should be immutable, so the C# record
type is a good fit. If you need
to store collections, you should do this using classes from the
System.Collections.Immutable
NuGet package.
Add a new class called PackageMatch and paste in the following code:
using System.Windows.Media; using Tql.Abstractions; namespace TqlNuGetPlugin; internal class PackageMatch(PackageDto dto) : IRunnableMatch { public string Text => dto.PackageId; public ImageSource Icon => Images.NuGetLogo; public MatchTypeId TypeId => TypeIds.Package; public Task Run(IServiceProvider serviceProvider, IWin32Window owner) { throw new NotImplementedException(); } } internal record PackageDto(string PackageId);
Add the following code to the
TypeIds
class:public static readonly MatchTypeId Package = new MatchTypeId( Guid.Parse("188fcd76-a9b9-485e-a0ae-bd5da448a668"), Plugin.PluginId );
Implement search
We'll implement search using the NuGet client NuGet packages.
Important
TQL uses Microsoft Depedency Injection. The ConfigureServices
method in your plugin class allows you to setup the DI container, and the
Initialize
method gives you access to the built service container. If your
plugin uses services, it's best practice to integrate with the DI container
TQL manages itself.
Add the NuGet.PackageManagement NuGet package to your class library. Next, we'll create our own client class to abstract over the NuGet package.
Add a new class called NuGetClient and paste in the following code:
using NuGet.Common; using NuGet.Configuration; using NuGet.Protocol.Core.Types; namespace TqlNuGetPlugin; internal class NuGetClient { private readonly SourceRepository _sourceRepository = new( new PackageSource("https://api.nuget.org/v3/index.json"), Repository.Provider.GetCoreV3() ); public async Task<IEnumerable<IPackageSearchMetadata>> Search( string query, CancellationToken cancellationToken ) { var searchResource = await _sourceRepository.GetResourceAsync<PackageSearchResource>( cancellationToken ); if (searchResource == null) throw new InvalidOperationException("Could not find search resource"); return await searchResource.SearchAsync( query, new SearchFilter(false), 0, 100, NullLogger.Instance, cancellationToken ); } }
Update the
ConfigureServices
method in thePlugin
class to the following:public void ConfigureServices(IServiceCollection services) { services.AddSingleton<NuGetClient>(); services.AddTransient<PackagesMatch>(); }
This adds both the
NuGetClient
class and thePackagesMatch
class.Add a primary constructor to the
PackagesMatch
class to have an instance of theNuGetClient
injected into it:internal class PackagesMatch(NuGetClient client) : ISearchableMatch
This does require us to make a few more updates to the
Plugin
class to instantiate thePackagesMatch
class using the DI container. Update theInitialize
andGetMatches
methods to the following:private IServiceProvider? _serviceProvider; public void Initialize(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public IEnumerable<IMatch> GetMatches() { yield return _serviceProvider!.GetRequiredService<PackagesMatch>(); }
We can now use this to implement search in the same class. Update the
Search
method to the following:public async Task<IEnumerable<IMatch>> Search( ISearchContext context, string text, CancellationToken cancellationToken ) { if (string.IsNullOrWhiteSpace(text)) return Array.Empty<IMatch>(); await context.DebounceDelay(cancellationToken); return from package in await client.Search(text, cancellationToken) select new PackageMatch(new PackageDto(package.Identity.Id)); }
There are a few important things to note here:
- We start by checking whether the user has actually typed in a search query. Some categories will return a default set of results if the search query is empty, but we don't. We just return an empty list instead.
- When we find that the user actually wants to search for something, we use
the
DebounceDelay
method to implement debounce. - Last we perform the search and turn the search results into a list of
PackageMatch
instances.
Start your project. You should be able to search for NuGet packages:
Activating matches
When the user activates a match, the Run
method on the runnable match class is
called. We'll implement this now.
Tip
In this example we're just going to open a URL. You can however show UI
to the user instead. If you do, use the owner
parameter passed into the
Run
method to correctly parent your window to the TQL search window. See
WindowInteropHelper.Owner
for information on how to do this with using WPF.
Important
The Run
method has a serviceProvider
parameter. This is a
reference to the service container. In the example below, we're using this
container to resolve the IUI
service. You could also inject this service
into the constructor. The reason we're going through the service provider
instead is that this limits memory usage of the TQL app.
The IUI
service is only needed in the Run
method. There will be far more
matches instantiated than that will be run. To not have to track references to
rarely used services in every match, TQL provides you with the service
container on a few methods like this.
This isn't as much of an issue for the PackagesMatch
class. Because that's a
category, very few instances will be instantiated for it. This is why we do
pass in the NuGetClient
service into the constructor of the PackagesMatch
class.
Update the
Run
method on thePackageMatch
class to the following:public Task Run(IServiceProvider serviceProvider, IWin32Window owner) { var url = $"https://www.nuget.org/packages/{Uri.EscapeDataString(dto.PackageId)}"; serviceProvider.GetRequiredService<IUI>().OpenUrl(url); return Task.CompletedTask; }
Start your project. You'll be able to activate the match and the NuGet page should open for you.
Serialization
We haven't implemented serialization yet. When a user opens a category or activates a match, it's automatically added to the history. Matches need to be serializable thought for this to work. In this step we'll add serialization and deserialization to our plugin.
Add the
ISerializableMatch
interface to thePackagesMatch
class.Add the following method to the
PackagesMatch
class:public string Serialize() { return JsonSerializer.Serialize(new PackagesDto()); }
Add the new DTO object to the bottom of the file:
internal record PackagesDto();
Note
Normally the DTO object should be passed into the constructor. We've done this for the
PackageMatch
class already. This however isn't required, and the above will work fine. However, if you'd later want to add support for e.g. multiple NuGet feeds, you would refactor this to have the DTO object passed in.Add the
ISerializableMatch
interface to thePackageMatch
class also and add the following method:public string Serialize() { return JsonSerializer.Serialize(dto); }
This takes care of serialization. To allow TQL to deserialize matches, we need
to implement the Deserialize
method on the Plugin
class. This is possible.
We could use the GUIDs in the TypeIds
class to detect which match and DTO
class we need to use and implement the logic that way. However, the utilities
NuGet package has some infrastructure to simplify this. We'll use that instead.
Create a new class called PackagesType and paste in the following code:
using Tql.Abstractions; using Tql.Utilities; namespace TqlNuGetPlugin; internal class PackagesType(IMatchFactory<PackagesMatch, PackagesDto> factory) : MatchType<PackagesMatch, PackagesDto>(factory) { public override Guid Id => TypeIds.Packages.Id; }
Add a class called PackageType and paste in the following code:
using Tql.Abstractions; using Tql.Utilities; namespace TqlNuGetPlugin; internal class PackageType(IMatchFactory<PackageMatch, PackageDto> factory) : MatchType<PackageMatch, PackageDto>(factory) { public override Guid Id => TypeIds.Package.Id; }
Tip
The MatchType
class handles deserialization for you. These
implementations are quite straight forward. The MatchType
class also has a
virtual IsValid
method. If you can validate DTO objects, e.g. against user
configuration or cached data, you should override this method to do so.
To use these classes, we need to add a IMatchTypeManager
to the Plugin
class.
Add the following fields to the
Plugin
class:private readonly MatchTypeManagerBuilder _matchTypeManagerBuilder = MatchTypeManagerBuilder.ForAssembly(typeof(Plugin).Assembly); private IMatchTypeManager? _matchTypeManager;
This adds a builder for the current assembly, and an field to store the built manager in.
Update the
ConfigureServices
method to the following:public void ConfigureServices(IServiceCollection services) { services.AddSingleton<NuGetClient>(); services.AddTransient<PackagesMatch>(); _matchTypeManagerBuilder.ConfigureServices(services); }
This allows the builder to add our match type classes to the DI container.
Update the
Initialize
method to build the manager:public void Initialize(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _matchTypeManager = _matchTypeManagerBuilder.Build(serviceProvider); }
Update the
Deserialize
method to the following:public IMatch? DeserializeMatch(Guid typeId, string value) { return _matchTypeManager!.Deserialize(typeId, value); }
Start your project.
You'll notice is that the pin icon appears if you hover over the NuGet packages match:
If you open the category, and re-open the search window, the NuGet packages match will have been added to the history:
And, same for the package matches of course:
And after we activate it:
Copying matches
The ICopyableMatch
interface gives TQL a way to copy matches. If you implement
this interface, a copy icon will be added next to your match. If the user clicks
it, TQL calls into the Copy
method on the interface to allow you to copy
something to the clipboard.
Tip
The example here implements the standard pattern for copyable matches.
The URL is the same as the one in the Run
method.
The IClipboard
service has helper methods for working with the clipboard.
The CopyUri
method copies a nicely formatted link to the clipboard, using
the Text
of the match as a label. There's also a CopyMarkdown
method that
allows you to format the labels of copied links, similar to what Azure DevOps
does.
Add the
ICopyableMatch
interface to thePackageMatch
class and add the following code:public Task Copy(IServiceProvider serviceProvider) { var url = $"https://www.nuget.org/packages/{Uri.EscapeDataString(dto.PackageId)}"; serviceProvider.GetRequiredService<IClipboard>().CopyUri(Text, url); return Task.CompletedTask; }
Start your project. The copy item should appear next to any package match:
If you click this and e.g. paste this into Word, you'll get the following: