/ src / modules / cmdpal / doc / command-pal-anatomy / command-palette-anatomy.md
command-palette-anatomy.md
  1  # Windows Command Palette Anatomy & Extension Development Guide
  2  
  3  ## Root View 
  4  
  5  Let's start with the root view of the Command Palette. The root view is the view that is displayed when the Command Palette is first opened. It is the first thing the user sees: 
  6  
  7  ![alt text](image-1.png)
  8  
  9  The root view is a special kind of [ListPage](#listpage). It's special because it displays all of the top level commands: [built-in commands](INSERT SECTION SOMEWHERE) and [commands provided by installed extensions](#icommandprovider).
 10  
 11  ![alt text](image.png)
 12  
 13  In this context, commands are just [ListItems](#listitem) that are displayed in the root view of Command Palette. In other words, all of the ListItems in the root view are known as commands.
 14  
 15  Users can search/filter for specific commands by typing in the [FilterBox](#filterbox). 
 16  
 17  ![alt text](image-2.png)
 18  
 19  The filter box will filter the commands displayed in the root view based on the user's input:
 20  
 21  ![alt text](6329067a-a75c-4280-86d2-d4e402f34ee5.gif)
 22  
 23  
 24  
 25  When a user clicks on a command, the [default command](#default-command-this-is-probably-not-the-right-spot-for-this) is executed. Alternatively, the user can press the `Enter` key to execute the default command associated with the selected command.
 26  
 27  ## Nested Views (Pages)
 28  
 29  When a command is executed, it can open a new "nested" view inside of the Command Palette. These views are known as pages.
 30  
 31  ### ListPage
 32  
 33  A list page is a page inside of command palette that displays a list of items. It has the following interface:
 34  
 35  ```csharp
 36  interface IListPage requires IPage {
 37      String SearchText { get; };
 38      String PlaceholderText { get; };
 39      Boolean ShowDetails{ get; };
 40      IFilters Filters { get; };
 41      IGridProperties GridProperties { get; };
 42  
 43      ISection[] GetItems(); // DevPal will be responsible for filtering the list of items
 44  }
 45  ```
 46  
 47  #### ISection
 48  
 49  #### GridProperties
 50  
 51  #### ShowDetails
 52  
 53  #### PlaceholderText
 54  
 55  #### SearchText
 56  
 57  ### MarkdownPage
 58  
 59  A markdown page is a page inside of command palette that displays markdown content. It has the following interface:
 60  
 61  ```csharp
 62   interface IMarkdownPage requires IPage {
 63       String[] Bodies(); // TODO! should this be an IBody, so we can make it observable?
 64       IDetails Details();
 65       IContextItem[] Commands { get; };
 66   }
 67   ```
 68  
 69  ### FormPage
 70  
 71  A form page is a page inside of command palette that displays a form. It has the following interface:
 72  
 73  ```csharp
 74   interface IFormPage requires IPage {
 75       IForm[] Forms();
 76   }
 77   ```
 78  
 79   ### Dynamic List Page
 80  
 81   A dynamic list page is similar to a list page, but it gives the developer control over how the list is filtered. It has the following interface:
 82  
 83   ```csharp
 84   interface IDynamicListPage requires IListPage {
 85      ISection[] GetItems(String query); // DevPal will do no filtering of these items
 86  }
 87  ```
 88  
 89  ## Additional Components 
 90  
 91  
 92  ### ListItem
 93  
 94  List items are items that are displayed on a [ListPage](#listpage). They can be clicked on to execute a command.
 95  
 96  #### Tags
 97  
 98  ### FilterBox
 99  
100  ## Anatomy of an Extension
101  
102  #### ICommandProvider
103  
104  This is the interface that an extension implements to provide top level commands to the Command Palette.
105  
106  **This is the entry point for all extensions into Command Palette.**
107  
108  ```csharp
109  public interface ICommandProvider
110  {
111      IEnumerable<ICommand> GetCommands();
112  }
113  ```
114  
115  #### ICommand
116  
117  This is the backbone of the Command Palette. It represents a unit of work that can be executed by the Command Palette.
118  
119  ListItems and ContextItems leverage the ICommand interface to execute commands. 
120  
121  ```csharp
122  public interface ICommand
123  {
124      string Name { get; }
125      void Execute();
126  }
127  ```
128  
129  #### Default Command (this is probably not the right spot for this)
130  
131  ### Building an Extension Example - SSH Keychain
132  
133  So you want to build an extension for the Command Palette? Let's walk through an example.
134  
135  #### Overview
136  
137  First, let's define the requirements for the SSH Keychain extension:
138  
139  1. The extension should provide a top level command in the Command Palette: "Search SSH Keys".
140  2. When the "Search SSH Keys" command is executed, a new page should be displayed in the Command Palette that lists all of the SSH hosts on the user's machine.
141  3. The user should be able to click on an SSH host or press `Enter` as the default command to launch Windows Terminal with an SSH session to the selected host. 
142  
143  When we're done building the extension, the Command Palette should look something like this:
144  
145  ![alt text](dddb317c-a9cb-4c29-b851-f1aeac855033.gif)
146  
147  #### Getting Started 
148  
149  We've made it easy to build a new extension. Just follow these steps:
150  
151  1. Navigate to the src\modules\cmdpal folder
152  2. Run the following PowerShell script, replacing "MastodonExtension" with the `Name` of your extension and "Mastodon extension for cmdpal" with the `DisplayName` of the [command that will show up in the root view](#root-view) of the Command Palette:
153      
154  ```powershell
155  .\ext\NewExtension.ps1 -name MastodonExtension -DisplayName "Mastodon extension for cmdpal"
156  ```
157  
158  3. Open the solution in Visual Studio.
159  4. Right click on the directory titled 'SampleExtensions' and select 'Add' -> 'Existing Project'.
160  
161  ![alt text](image-12.png)
162  
163  5. Navigate to the '\Exts folder, and find the *.csproj file that was created by the PowerShell script. It will be in a folder with the same name as the extension you provided in the PowerShell script.
164  6. Right click on the new project you've just created and select 'Deploy'.
165  7. Build the project by clicking the play button in Visual Studio.
166  
167  ![alt text](image-11.png)
168  
169  At this point you should have your command that you created show up in the root view of the Command Palette! If you've set up everything correctly, it should look something like this (note - your example won't have icons yet): 
170  
171  
172  ![alt text](44e3593f-cfc0-4df3-ba74-dede09b0de5a.gif)
173  
174  #### Step 1: Provide a Top Level Command
175  
176  In order for users to interact with your extension in the Command Palette, **you need to provide at least one top level command**. To do this, implement the `ICommandProvider` interface located in the `*CommandsProvider.cs` file created by the extension template. This interface has a single method, `TopLevelCommands()`, that returns a list of commands that will be displayed in the root view of the Command Palette. Remember, each top level command that a user sees is represented by a [ListItem](#listitem). In other words, the `TopLevelCommands()` method will return a list of `ListItems`, where each `ListItem` represents a command that will be displayed in the [root view](#root-view) of the Command Palette.
177  
178  ```csharp
179  public class SSHKeychainCommandsProvider : CommandProvider
180  {
181      public SSHKeychainCommandsProvider()
182      {
183          DisplayName = "SSH Keychain Commands";
184      }
185  
186      private readonly ICommandItem[] _commands = [
187         new CommandItem(new NoOpCommand())
188          {
189              Title = "Search SSH Keys",
190              Subtitle = "Quickly find and launch into hosts from your SSH config file",
191          },
192      ];
193  
194      public override ICommandItem[] TopLevelCommands()
195      {
196          return _commands;
197      }
198  }
199  ```
200  
201  We recommend that you follow the template guidelines and put your implementation for the ICommandProvider inside of its own file . In this case, we have created an `SSHKeychainCommandProvider` class that will provide the required "Search SSH Keys" command inside of `SSHKeychainCommandProvider.cs`.
202  
203  Let's see what this currently looks like in the Command Palette. First, deploy your extension in Visual Studio by right-clicking on the project and selecting "Deploy":
204  
205  ![alt text](image-3.png)
206  
207  Then, open the Command Palette by pressing `Win+Ctrl+.` and search for "Search SSH Keys". You should see the command displayed in the root view of the Command Palette like this:
208  
209  ![alt text](image-4.png)
210  
211  And there you have it. We've successfully provided a top level command in the Command Palette! Go ahead and try to change the title and subtitle of the ListItem to see how it affects the display in the Command Palette! **You'll have to rebuild your project and deploy it again to see the changes.**
212  
213  #### Step 2: Implement the Command Execution
214  
215  In Step 1 we provided a top level command in the Command Palette, but it doesn't currently do anything. That's because the ListItem we've provided didn't provide any associated commands that could be executed!
216  
217  ```csharp
218  private readonly IListItem[] _commands = [
219         new ListItem(new NoOpCommand())
220          {
221              Title = "Search SSH Keys",
222              Subtitle = "Quickly find and launch into hosts from your SSH config file",
223          },
224      ];
225  ```
226  Notice how in the snippet above, we've provided a `NoOpCommand()` value for the `Command` property of the `ListItem`. This is why the command doesn't currently do anything.
227  
228   So, we now need to implement the command execution logic. When the user clicks on the "Search SSH Keys" command, we want to display a new [page](#nested-views-pages) in the Command Palette that lists all of the SSH hosts on the user's machine. 
229  
230   Let's start by providing a new [ListPage](#listpage) command that will be executed by default when a user either clicks on the "Search SSH Keys" ListItem or has the ListItem selected and presses `Enter`. For now, let's just display a simple message to the user in the new page when the command is executed.
231  
232   Following the template guidelines, we will create a new file inside of the 'Pages' folder. All of the [pages](#nested-views-pages) your extension uses should be located in the pages folder. In this case, we will create a new class called `SSHHostsListPage` because we will be displaying a list of SSH hosts on this page. 
233  
234   ```csharp
235   internal sealed class SSHHostsListPage : ListPage
236  {
237      public SSHHostsListPage()
238      {
239          Icon = new(string.Empty);
240          Name = "SSH Keychain";
241      }
242  
243      public override ISection[] GetItems()
244      {
245          return [
246              new ListSection()
247              {
248                  Title = "SSH Hosts",
249                  Items = [
250                      new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" }
251                  ],
252              }
253          ];
254      }
255  }
256   ```
257  
258  A list page expects an array of [`ISection`](#isection) objects to be returned from the `GetItems()` method. Take a look at the HackerNewsPage as an example of what a ListPage with a section might look like: 
259  
260  ![alt text](image-7.png "Example of List Section Title & Page Icon, Name")
261  
262  In the HackerNews example, we return one section with a bunch of list items. However, in our example code above, we are returning a single `ListSection` with a single `ListItem` that has a `NoOpCommand` associated with it. The `NoOpCommand` is a simple action that does nothing when executed. Here is a reminder of the ListSection we are returning: 
263  
264  ```csharp
265  new ListSection()
266              {
267                  Title = "SSH Hosts",
268                  Items = [
269                      new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" }
270                  ],
271              }
272  ```
273  
274   In order for our root command, "Search SSH Hosts" to execute the command to our new page, we need to update the `SSHKeychainCommandsProvider` class from [step 1](#step-1-provide-a-top-level-command) to provide the `SSHHostsListPage` command when the "Search SSH Keys" list item has its default command executed. Specifically, we need to update the `Command` property of the `ListItem` to be the `SSHHostsListPage` class we just created. Inside of SSHKeychainCommandsProvider.cs, the code should look like this:
275  
276   ```csharp
277  // Code in Provider before we update the Command property of the ListItem. There is no default command associated with the ListItem.
278  
279  private readonly IListItem[] _commands = [
280         new ListItem(new NoOpCommand())
281          {
282              Title = "Search SSH Keys",
283              Subtitle = "Quickly find and launch into hosts from your SSH config file",
284          },
285      ];
286  
287  // Code in Provider after we update the Command property of the ListItem. We are updating the default command.
288  
289  private readonly IListItem[] _commands = [
290         new ListItem(new SSHHostsListPage())
291          {
292              Title = "Search SSH Keys",
293              Subtitle = "Quickly find and launch into hosts from your SSH config file",
294          },
295      ];
296  ```
297  
298  Let's rebuild our project and see how the changes appear in Command Palette.
299  
300  You should see the `Search SSH Keys` Command  in the root view of the palette like this:
301  
302   ![alt text](image-5.png "Root View of Command Palette with our new Search SSH Keys Command")
303  
304  Now when you [invoke the default command](#default-command-this-is-probably-not-the-right-spot-for-this) to be executed for the `Search SSH Keys` command in the root view, you should see a new page displayed in the Command Palette with the message "TODO: Implement your extension here":
305  
306  ![alt text](image-8.png "New ListPage with a single ListItem that says 'TODO: Implement your extension here'")
307  
308  - When the "Search SSH Keys" command is executed, a new page should be displayed in the Command Palette ✅
309  - TODO: Implement the logic that will parse the SSH config file to actually display the available SSH hosts on the machine in the Command Palette.
310  
311  Before we get to implementing the logic to display the SSH hosts, let's take this example one step farther by providing a few list items that will be displayed in the `SSHHostsListPage`. 
312  
313  ```csharp
314  internal sealed class SSHHostsListPage : ListPage
315  {
316      public SSHHostsListPage()
317      {
318          Icon = new(string.Empty);
319          Name = "SSH Keychain";
320      }
321  
322      public override ISection[] GetItems()
323      {
324          return [
325              new ListSection()
326              {
327                  Title = "SSH Hosts",
328                  Items = [
329                      new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" },
330                      new ListItem(new NoOpCommand()) { Title = "Another list item", Subtitle = "this one has a subtitle" },
331                      new ListItem(new NoOpCommand())
332                      {
333                          Title = "A list item with a tag",
334                          Subtitle = "this one with tags",
335                          Tags = [new Tag()
336                                 {
337                                     Text = "Example Tag",
338                                 }
339                                 ],
340                      }
341                  ],
342              }
343          ];
344      }
345  }
346  ```
347  
348  In the snippet above, we've added two additional `ListItem` objects to the `Items` array of the `ListSection`. Now the second `ListItem` has a `Subtitle` property set, and the third `ListItem` has both a subtitle set and a [`Tag`](#tags) associated with it.
349  
350  Let's take a look at how this change looks in the Command Palette:
351  
352  ![alt text](image-9.png)
353  
354  Following the documentation above, we're at the point where we can: 
355  
356  - Add a top level command to the Command Palette ✅
357  - Display a new `ListPage` in the Command Palette when the command is executed ✅
358  - Display a list of items in the new page ✅
359  
360  For the SSH Keychain extension, we want to display a list of SSH hosts on the user's machine. We'll then replace these example `ListItem` objects with the actual SSH hosts that we parse from the SSH config file.
361  
362  First, let's add a file to the 'Data' folder called `SSHKeychainItem.cs`. This file will contain a class that represents the information that we need to display for each SSH host.
363  
364  ```csharp
365  internal class SSHKeychainItem
366  {
367      internal string Host { get; init; }
368  
369      internal string EscapedHost => JsonEncodedText.Encode(Host).ToString();
370  }
371  ```
372  
373  We recommend that you add any data classes that you need to the 'Data' folder of your extension. This will help keep your project organized.
374  
375  Next, we need to update the `SSHHostsListPage` class to display the actual SSH hosts that we parse from the SSH config file rather than the sample list items we've shown before. We'll need to update the `GetItems()` method to return a list of `ListItem` objects that represent the actual SSH hosts. The entire `SSHHostsListPage` class should look like this:
376  
377  ```csharp
378  internal sealed partial class SSHHostsListPage : ListPage
379  {
380      private static readonly string _defaultConfigFile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "\\.ssh\\config";
381  
382      private static readonly Regex _hostRegex = new(@"^Host\s+(?:(\S*) ?)*?\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline);
383  
384      public SSHHostsListPage()
385      {
386          Icon = new(string.Empty);
387          Name = "SSH Keychain";
388      }
389  
390      private static async Task<List<SSHKeychainItem>> GetSSHHosts()
391      {
392          var hosts = new List<SSHKeychainItem>();
393          var configFile = _defaultConfigFile;
394  
395          if (!File.Exists(configFile))
396          {
397              return hosts;
398          }
399  
400          var options = new FileStreamOptions()
401          {
402              Access = FileAccess.Read,
403          };
404  
405          using var reader = new StreamReader(configFile, options);
406          var fileContent = await reader.ReadToEndAsync();
407  
408          if (!string.IsNullOrEmpty(fileContent))
409          {
410              var matches = _hostRegex.Matches(fileContent);
411              hosts = matches.Select(match => new SSHKeychainItem { HostName = match.Groups[1].Value }).ToList();
412          }
413  
414          return hosts;
415      }
416  
417      public override ISection[] GetItems()
418      {
419          var t = DoGetItems();
420          t.ConfigureAwait(false);
421          return t.Result;
422      }
423  
424      private async Task<ISection[]> DoGetItems()
425      {
426          List<SSHKeychainItem> items = await GetSSHHosts();
427          var s = new ListSection()
428          {
429              Title = "SSH Hosts",
430              Items = items.Select((host) => new ListItem(new NoOpCommand())
431              {
432                  Title = host.HostName,
433                  Subtitle = host.EscapedHost,
434              }).ToArray(),
435          };
436          return [s];
437      }
438  }
439  ```
440  
441  Let's break down what's happening in the `SSHHostsListPage` class:
442  
443  1. We've added a `GetSSHHosts()` method that reads the SSH config file and parses out the host names. This method returns a list of `SSHKeychainItem` objects that represent the SSH hosts on the user's machine. **Notice how this is an async operation!**
444  
445  2. GetItems() now calls DoGetItems() which is an async method that gets the SSH hosts and creates a `ListSection` object with a list of `ListItem` objects that represent the SSH hosts. **If you're planning on doing any async operations in your `GetItems()` method, you should follow this pattern**
446  
447  3. Inside of DoGetItems(), we call GetSSHHosts() to get the SSH hosts and then create a `ListSection` object with a list of `ListItem` objects that represent the SSH hosts. We are able to then create the proper title and subtitle for each `ListItem` object.
448  
449  Now that we've updated the `SSHHostsListPage` class to display the actual SSH hosts on the user's machine, let's rebuild our project and see how the changes appear in the Command Palette. **NOTE: You will need to have an SSH config file on your machine for this to work.**
450  
451  ![alt text](image-10.png)
452  
453  Awesome! We've successfully displayed a list of SSH hosts on the user's machine in the Command Palette! We can even search for specific hosts by typing in the filter box:
454  
455  ![alt text](708b3307-c0eb-4e6d-8e03-d3a7e564e354.gif)
456  
457  Filtering the list of SSH hosts is a built-in feature of the Command Palette. You don't have to do anything special to enable this functionality. The Command Palette will automatically filter the list of items displayed in the Command Palette based on the user's input in the filter box using a fuzzy search. However, if you'd like to handle the filtering yourself, you can create a [Dynamic List Page](#dynamic-list-page) instead of a [List Page](#listpage).
458  
459  #### Step 3: Implement Command for SSH Hosts
460  
461  Now that we have a list of SSH hosts displayed in the Command Palette, we want to be able to launch Windows Terminal with an SSH session to the selected host when the user clicks on a host or presses `Enter` as the default command.
462  
463  To do this, we need to update the `ListItem` objects in the `SSHHostsListPage` class to have a command associated with them that will launch Windows Terminal with an SSH session to the selected host. We'll create a new class called `LaunchSSHSessionCommand` that will be responsible for launching Windows Terminal with an SSH session to the selected host.
464  
465  Following the template guidelines, we will create a new file inside of the 'Commands' folder. In this case, we will create a new class called `LaunchSSHSessionCommand` that will be responsible for launching Windows Terminal with an SSH session to the selected host:
466  
467  ```csharp
468  internal sealed partial class LaunchSSHHostCommand : InvokableCommand
469  {
470      private readonly SSHKeychainItem _host;
471  
472      internal LaunchSSHHostCommand(SSHKeychainItem host)
473      {
474          this._host = host;
475          this.Name = "Connect";
476          this.Icon = new("\uE8A7");
477      }
478  
479      public override CommandResult Invoke()
480      {
481          try
482          {
483              Process.Start("cmd.exe", $"/k ssh {_host.HostName}");
484          }
485          catch
486          {
487              Process.Start(new ProcessStartInfo("cmd.exe", $"/k ssh {_host.HostName}") { UseShellExecute = true });
488          }
489  
490          return CommandResult.KeepOpen();
491      }
492  }
493  ```
494  
495  That's it! We've successfully implemented the command that will launch Windows Terminal with an SSH session to the selected host. Now we need to update the `ListItem` objects in the `SSHHostsListPage` class to have the `LaunchSSHSessionCommand` associated with them. Specifically, we need to update the `Command` property of the `ListItem` objects to be the `LaunchSSHSessionCommand` class we just created. Inside of `SSHHostsListPage.cs`, the code should look like this:
496  
497  ```csharp
498      private async Task<ISection[]> DoGetItems()
499      {
500          List<SSHKeychainItem> items = await GetSSHHosts();
501          var s = new ListSection()
502          {
503              Title = "SSH Hosts",
504              Items = items.Select((host) => new ListItem(new LaunchSSHHostCommand(host))
505              {
506                  Title = host.HostName,
507                  Subtitle = host.EscapedHost,
508              }).ToArray(),
509          };
510          return [s];
511      }
512  ```
513  
514  All we had to change was the `NoOpCommand` to the `LaunchSSHHostCommand` in the `ListItem` object. Now when the user clicks on a host or presses `Enter` as the default command, Windows Terminal will launch with an SSH session to the selected host! Take a look at how this looks in the Command Palette:
515  
516  ![alt text](664bf405-b176-48de-804e-9075177025ed.gif)