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 }