/ src / modules / cmdpal / ext / Microsoft.CmdPal.Ext.WinGet / Pages / WinGetExtensionPage.cs
WinGetExtensionPage.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System;
  6  using System.Collections.Generic;
  7  using System.Diagnostics;
  8  using System.Diagnostics.CodeAnalysis;
  9  using System.Globalization;
 10  using System.Linq;
 11  using System.Text;
 12  using System.Threading;
 13  using System.Threading.Tasks;
 14  using ManagedCommon;
 15  using Microsoft.CmdPal.Ext.WinGet.Pages;
 16  using Microsoft.CommandPalette.Extensions;
 17  using Microsoft.CommandPalette.Extensions.Toolkit;
 18  using Microsoft.Management.Deployment;
 19  
 20  namespace Microsoft.CmdPal.Ext.WinGet;
 21  
 22  internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable
 23  {
 24      private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Properties.Resources.winget_unexpected_error);
 25  
 26      private readonly string _tag = string.Empty;
 27  
 28      public bool HasTag => !string.IsNullOrEmpty(_tag);
 29  
 30      private readonly Lock _resultsLock = new();
 31      private readonly Lock _taskLock = new();
 32  
 33      private string? _nextSearchQuery;
 34      private bool _isTaskRunning;
 35  
 36      private List<CatalogPackage>? _results;
 37  
 38      public static string ExtensionsTag => "windows-commandpalette-extension";
 39  
 40      private readonly StatusMessage _errorMessage = new() { State = MessageState.Error };
 41  
 42      public WinGetExtensionPage(string tag = "")
 43      {
 44          Icon = tag == ExtensionsTag ? Icons.ExtensionsIcon : Icons.WinGetIcon;
 45          Name = Properties.Resources.winget_page_name;
 46          _tag = tag;
 47          ShowDetails = true;
 48      }
 49  
 50      public override IListItem[] GetItems()
 51      {
 52          lock (_resultsLock)
 53          {
 54              // emptySearchForTag ===
 55              // we don't have results yet, we haven't typed anything, and we're searching for a tag
 56              var emptySearchForTag = _results is null &&
 57                  string.IsNullOrEmpty(SearchText) &&
 58                  HasTag;
 59  
 60              if (emptySearchForTag)
 61              {
 62                  IsLoading = true;
 63                  DoUpdateSearchText(string.Empty);
 64                  return [];
 65              }
 66  
 67              if (_results is not null && _results.Count != 0)
 68              {
 69                  var stopwatch = Stopwatch.StartNew();
 70                  var count = _results.Count;
 71                  var results = new ListItem[count];
 72                  var next = 0;
 73                  for (var i = 0; i < count; i++)
 74                  {
 75                      try
 76                      {
 77                          var li = PackageToListItem(_results[i]);
 78                          results[next] = li;
 79                          next++;
 80                      }
 81                      catch (Exception ex)
 82                      {
 83                          Logger.LogError("error converting result to listitem", ex);
 84                      }
 85                  }
 86  
 87                  stopwatch.Stop();
 88                  Logger.LogDebug($"Building ListItems took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(GetItems));
 89                  return results;
 90              }
 91          }
 92  
 93          EmptyContent = new CommandItem(new NoOpCommand())
 94          {
 95              Icon = Icons.WinGetIcon,
 96              Title = (string.IsNullOrEmpty(SearchText) && !HasTag) ?
 97                              Properties.Resources.winget_placeholder_text :
 98                              Properties.Resources.winget_no_packages_found,
 99          };
100  
101          return [];
102      }
103  
104      private static ListItem PackageToListItem(CatalogPackage p) => new InstallPackageListItem(p);
105  
106      public override void UpdateSearchText(string oldSearch, string newSearch)
107      {
108          if (newSearch == oldSearch)
109          {
110              return;
111          }
112  
113          DoUpdateSearchText(newSearch);
114      }
115  
116      private void DoUpdateSearchText(string newSearch)
117      {
118          lock (_taskLock)
119          {
120              if (_isTaskRunning)
121              {
122                  // If a task is running, queue the next search query
123                  // Keep IsLoading = true since we still have work to do
124                  Logger.LogDebug($"Task is running, queueing next search: '{newSearch}'", memberName: nameof(DoUpdateSearchText));
125                  _nextSearchQuery = newSearch;
126              }
127              else
128              {
129                  // No task is running, start a new search
130                  Logger.LogDebug($"Starting new search: '{newSearch}'", memberName: nameof(DoUpdateSearchText));
131                  _isTaskRunning = true;
132                  _nextSearchQuery = null;
133                  IsLoading = true;
134  
135                  _ = ExecuteSearchChainAsync(newSearch);
136              }
137          }
138      }
139  
140      private async Task ExecuteSearchChainAsync(string query)
141      {
142          while (true)
143          {
144              try
145              {
146                  Logger.LogDebug($"Executing search for '{query}'", memberName: nameof(ExecuteSearchChainAsync));
147  
148                  var results = await DoSearchAsync(query);
149  
150                  // Update UI with results
151                  UpdateWithResults(results, query);
152              }
153              catch (Exception ex)
154              {
155                  Logger.LogError($"Unexpected error while searching for '{query}'", ex);
156              }
157  
158              // Check if there's a next query to process
159              string? nextQuery;
160              lock (_taskLock)
161              {
162                  if (_nextSearchQuery is not null)
163                  {
164                      // There's a queued search, execute it
165                      nextQuery = _nextSearchQuery;
166                      _nextSearchQuery = null;
167  
168                      Logger.LogDebug($"Found queued search, continuing with: '{nextQuery}'", memberName: nameof(ExecuteSearchChainAsync));
169                  }
170                  else
171                  {
172                      // No more searches queued, mark task as completed
173                      _isTaskRunning = false;
174                      IsLoading = false;
175                      Logger.LogDebug("No more queued searches, task chain completed", memberName: nameof(ExecuteSearchChainAsync));
176                      break;
177                  }
178              }
179  
180              // Continue with the next query
181              query = nextQuery;
182          }
183      }
184  
185      private void UpdateWithResults(IEnumerable<CatalogPackage> results, string query)
186      {
187          Logger.LogDebug($"Completed search for '{query}'");
188          lock (_resultsLock)
189          {
190              this._results = results.ToList();
191          }
192  
193          RaiseItemsChanged();
194      }
195  
196      private async Task<IEnumerable<CatalogPackage>> DoSearchAsync(string query)
197      {
198          Stopwatch stopwatch = new();
199          stopwatch.Start();
200  
201          if (string.IsNullOrEmpty(query)
202              && string.IsNullOrEmpty(_tag))
203          {
204              return [];
205          }
206  
207          var searchDebugText = $"{query}{(HasTag ? "+" : string.Empty)}{_tag}";
208          Logger.LogDebug($"Starting search for '{searchDebugText}'");
209          HashSet<CatalogPackage> results = new(new PackageIdCompare());
210  
211          // Default selector: this is the way to do a `winget search <query>`
212          var selector = WinGetStatics.WinGetFactory.CreatePackageMatchFilter();
213          selector.Field = Microsoft.Management.Deployment.PackageMatchField.CatalogDefault;
214          selector.Value = query;
215          selector.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
216  
217          var opts = WinGetStatics.WinGetFactory.CreateFindPackagesOptions();
218          opts.Selectors.Add(selector);
219  
220          // testing
221          opts.ResultLimit = 25;
222  
223          // Selectors is "OR", Filters is "AND"
224          if (HasTag)
225          {
226              var tagFilter = WinGetStatics.WinGetFactory.CreatePackageMatchFilter();
227              tagFilter.Field = Microsoft.Management.Deployment.PackageMatchField.Tag;
228              tagFilter.Value = _tag;
229              tagFilter.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
230  
231              opts.Filters.Add(tagFilter);
232          }
233  
234          var catalogTask = HasTag ? WinGetStatics.CompositeWingetCatalog : WinGetStatics.CompositeAllCatalog;
235  
236          // Both these catalogs should have been instantiated by the
237          // WinGetStatics static ctor when we were created.
238          var catalog = await catalogTask.Value;
239  
240          if (catalog is null)
241          {
242              // This error should have already been displayed by WinGetStatics
243              return [];
244          }
245  
246          // foreach (var catalog in connections)
247          {
248              Stopwatch findPackages_stopwatch = new();
249              findPackages_stopwatch.Start();
250              Logger.LogDebug($"  Searching {catalog.Info.Name} ({query})", memberName: nameof(DoSearchAsync));
251  
252              Logger.LogDebug($"Preface for \"{searchDebugText}\" took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync));
253  
254              // BODGY, re: microsoft/winget-cli#5151
255              // FindPackagesAsync isn't actually async.
256              var internalSearchTask = Task.Run(() => catalog.FindPackages(opts));
257              var searchResults = await internalSearchTask;
258  
259              findPackages_stopwatch.Stop();
260              Logger.LogDebug($"FindPackages for \"{searchDebugText}\" took {findPackages_stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync));
261  
262              // TODO more error handling like this:
263              if (searchResults.Status != FindPackagesResultStatus.Ok)
264              {
265                  _errorMessage.Message = string.Format(CultureInfo.CurrentCulture, ErrorMessage, searchResults.Status);
266                  WinGetExtensionHost.Instance.ShowStatus(_errorMessage, StatusContext.Page);
267                  return [];
268              }
269  
270              Logger.LogDebug($"    got results for ({query})", memberName: nameof(DoSearchAsync));
271  
272              // FYI Using .ToArray or any other kind of enumerable loop
273              // on arrays returned by the winget API are NOT trim safe
274              var count = searchResults.Matches.Count;
275              for (var i = 0; i < count; i++)
276              {
277                  var match = searchResults.Matches[i];
278  
279                  var package = match.CatalogPackage;
280                  results.Add(package);
281              }
282  
283              Logger.LogDebug($"    ({searchDebugText}): count: {results.Count}", memberName: nameof(DoSearchAsync));
284          }
285  
286          stopwatch.Stop();
287  
288          Logger.LogDebug($"Search \"{searchDebugText}\" took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync));
289  
290          return results;
291      }
292  
293      public void Dispose() => throw new NotImplementedException();
294  }
295  
296  [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "I just like it")]
297  public sealed class PackageIdCompare : IEqualityComparer<CatalogPackage>
298  {
299      public bool Equals(CatalogPackage? x, CatalogPackage? y) =>
300          (x?.Id == y?.Id)
301          && (x?.DefaultInstallVersion?.PackageCatalog == y?.DefaultInstallVersion?.PackageCatalog);
302  
303      public int GetHashCode([DisallowNull] CatalogPackage obj) => obj.Id.GetHashCode();
304  }