From 4d1f92199c4dbf10edf426e98c3c47518cd1e61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Mon, 2 Feb 2026 18:23:34 +0100 Subject: [PATCH] CmdPal: Make Indexer great again - part 1 - hotfix (#44729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR introduces a rough hotfix for several indexer-related issues. - Premise: patch what we can in-place and fix the core later (reworking `SeachEngine` and `SearchQuery` is slightly trickier). This patch also removes some dead code for future refactor. - Adds search cancellation to the File Search page and the indexer fallback. - Prevents older searches from overwriting newer model state and reduces wasted work. - Stops reusing the search engine; creates a new instance per search to avoid synchronization issues. - That `SeachEngine` and `SearchQuery` are not multi-threading friendly. - Removes search priming to simplify the code and improve performance. - Since `SearchQuery` cancels and re-primes on every search, priming provides little benefit and can hide extra work (for example, cancellation triggering re-priming). - Fixes the indexer fallback subject line not updating when there is more than one match. - It previously kept the old value, which was confusing. - ~Shows the number of matched files in the fallback result.~ - Fetching total number of rows was reverted, performance was not stable :( - Optimizes the indexer fallback by reducing the number of items processed but not used. - Only fetches the item(s) needed for the fallback itself—no extra work on the hot path. - Stops reusing the fallback result when navigating to the File Search page to show more results. This requires querying again, but it simplifies the flow and keeps components isolated. - Fixes the English mnemonic keyword `kind` being hardcoded in the search page filter. Windows Search uses localized mnemonic keyword names, so this PR replaces it with canonical keyword `System.Kind` that is universaly recognized. - Adds extra diagnostics to `SearchQuery` and makes logging more precise. - DataPackage for the IndexerListItem now defers including of storage items - boost performance and we avoid touching the item until its needed. - IndexerPage with a prepopulated query will delay loading until the items are actually enumerated (to avoid populating from fallback hot path) and it no longer takes external SearchEngine. It just recreates everything. ## PR Checklist - [x] Related to: #44728 - [x] Closes: #44731 - [x] Closes: #44732 - [x] Closes: #44743 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + .../FallbackOpenFileItem.cs | 295 ++++++++++------ .../Helpers/DataPackageHelper.cs | 72 ++-- .../Indexer/SearchQuery.cs | 314 +++++++----------- .../Indexer/Utils/QueryStringBuilder.cs | 13 +- .../Pages/IndexerPage.cs | 200 ++++++++--- .../Properties/Resources.Designer.cs | 11 +- .../Properties/Resources.resx | 3 + .../SearchEngine.cs | 111 ++++--- 9 files changed, 614 insertions(+), 406 deletions(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 15e48cff17..9d6188fa24 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -44,6 +44,7 @@ ALLCHILDREN ALLINPUT Allman Allmodule +ALLNOISE ALLOWUNDO ALLVIEW ALPHATYPE diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs index 967e962085..96f66729ac 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs @@ -2,33 +2,43 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#nullable enable + using System; using System.Globalization; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Indexer.Data; using Microsoft.CmdPal.Ext.Indexer.Helpers; using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; using Windows.Storage.Streams; namespace Microsoft.CmdPal.Ext.Indexer; -internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System.IDisposable +internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDisposable { - private const string _id = "com.microsoft.cmdpal.builtin.indexer.fallback"; - private static readonly NoOpCommand _baseCommandWithId = new() { Id = _id }; + private const string CommandId = "com.microsoft.cmdpal.builtin.indexer.fallback"; - private readonly CompositeFormat fallbackItemSearchPageTitleCompositeFormat = CompositeFormat.Parse(Resources.Indexer_fallback_searchPage_title); + // Cookie to identify our queries; since we replace the SearchEngine on each search, + // this can be a constant. + private const uint HardQueryCookie = 10; + private static readonly NoOpCommand BaseCommandWithId = new() { Id = CommandId }; - private readonly SearchEngine _searchEngine = new(); + private readonly CompositeFormat _fallbackItemSearchPageTitleFormat = CompositeFormat.Parse(Resources.Indexer_fallback_searchPage_title); + private readonly CompositeFormat _fallbackItemSearchSubtitleMultipleResults = CompositeFormat.Parse(Resources.Indexer_Fallback_MultipleResults_Subtitle); + private readonly Lock _querySwitchLock = new(); + private readonly Lock _resultLock = new(); - private uint _queryCookie = 10; - - private Func _suppressCallback; + private CancellationTokenSource? _currentQueryCts; + private Func? _suppressCallback; public FallbackOpenFileItem() - : base(_baseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, _id) + : base(BaseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, CommandId) { Title = string.Empty; Subtitle = string.Empty; @@ -37,118 +47,209 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System public override void UpdateQuery(string query) { - if (string.IsNullOrWhiteSpace(query)) - { - Command = new NoOpCommand(); - Title = string.Empty; - Subtitle = string.Empty; - Icon = null; - MoreCommands = null; - DataPackage = null; + UpdateQueryCore(query); + } + private void UpdateQueryCore(string query) + { + // Calling this will cancel any ongoing query processing. We always use a new SearchEngine + // instance per query, as SearchEngine.Query cancels/reinitializes internally. + CancellationToken cancellationToken; + + lock (_querySwitchLock) + { + _currentQueryCts?.Cancel(); + _currentQueryCts?.Dispose(); + _currentQueryCts = new CancellationTokenSource(); + cancellationToken = _currentQueryCts.Token; + } + + var suppressCallback = _suppressCallback; + if (string.IsNullOrWhiteSpace(query) || (suppressCallback is not null && suppressCallback(query))) + { + ClearResultForCurrentQuery(cancellationToken); return; } - if (_suppressCallback is not null && _suppressCallback(query)) + try { - Command = new NoOpCommand(); - Title = string.Empty; - Subtitle = string.Empty; - Icon = null; - MoreCommands = null; - DataPackage = null; - - return; - } - - if (Path.Exists(query)) - { - // Exit 1: The query is a direct path to a file. Great! Return it. - var item = new IndexerItem(fullPath: query); - var listItemForUs = new IndexerListItem(item, IncludeBrowseCommand.AsDefault); - Command = listItemForUs.Command; - MoreCommands = listItemForUs.MoreCommands; - Subtitle = item.FileName; - Title = item.FullPath; - Icon = listItemForUs.Icon; - DataPackage = DataPackageHelper.CreateDataPackageForPath(listItemForUs, item.FullPath); - - try + var exists = Path.Exists(query); + if (exists) { - var stream = ThumbnailHelper.GetThumbnail(item.FullPath).Result; - if (stream is not null) + ProcessDirectPath(query, cancellationToken); + } + else + { + ProcessSearchQuery(query, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Query was superseded by a newer one - discard silently. + } + catch + { + if (!cancellationToken.IsCancellationRequested) + { + ClearResultForCurrentQuery(cancellationToken); + } + } + } + + private void ProcessDirectPath(string query, CancellationToken ct) + { + var item = new IndexerItem(fullPath: query); + var indexerListItem = new IndexerListItem(item, IncludeBrowseCommand.AsDefault); + + ct.ThrowIfCancellationRequested(); + UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct); + _ = LoadIconAsync(item.FullPath, ct); + } + + private void ProcessSearchQuery(string query, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // for now the SearchEngine and SearchQuery are not thread-safe, so we create a new instance per query + // since SearchEngine will re-initialize on a new query anyway, it doesn't seem to be a big overhead for now + var searchEngine = new SearchEngine(); + + try + { + searchEngine.Query(query, queryCookie: HardQueryCookie); + ct.ThrowIfCancellationRequested(); + + // We only need to know whether there are 0, 1, or more than one result + var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true); + var count = results.Count; + + if (count == 0) + { + ClearResultForCurrentQuery(ct); + } + else if (count == 1) + { + if (results[0] is IndexerListItem indexerListItem) { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - Icon = new IconInfo(data, data); + UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct); + _ = LoadIconAsync(indexerListItem.FilePath, ct); + } + else + { + ClearResultForCurrentQuery(ct); } } - catch + else { + var indexerPage = new IndexerPage(query); + + var set = UpdateResultForCurrentQuery( + string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchPageTitleFormat, query), + string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchSubtitleMultipleResults), + Icons.FileExplorerIcon, + indexerPage, + MoreCommands, + DataPackage, + skipIcon: false, + ct); + + if (!set) + { + // if we failed to set the result (query was cancelled), dispose the page and search engine + indexerPage.Dispose(); + } } - - return; } - else + finally { - _queryCookie++; + searchEngine?.Dispose(); + } + } - try + private async Task LoadIconAsync(string path, CancellationToken ct) + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(path).ConfigureAwait(false); + if (stream is null || ct.IsCancellationRequested) { - _searchEngine.Query(query, _queryCookie); - var results = _searchEngine.FetchItems(0, 20, _queryCookie, out var _); - - if (results.Count == 0 || (results[0] is not IndexerListItem indexerListItem)) - { - // Exit 2: We searched for the file, and found nothing. Oh well. - // Hide ourselves. - Title = string.Empty; - Subtitle = string.Empty; - Command = new NoOpCommand(); - MoreCommands = null; - DataPackage = null; - return; - } - - if (results.Count == 1) - { - // Exit 3: We searched for the file, and found exactly one thing. Awesome! - // Return it. - Title = indexerListItem.Title; - Subtitle = indexerListItem.Subtitle; - Icon = indexerListItem.Icon; - Command = indexerListItem.Command; - MoreCommands = indexerListItem.MoreCommands; - DataPackage = DataPackageHelper.CreateDataPackageForPath(indexerListItem, indexerListItem.FilePath); - - return; - } - - // Exit 4: We found more than one result. Make our command take - // us to the file search page, prepopulated with this search. - var indexerPage = new IndexerPage(query, _searchEngine, _queryCookie, results); - Title = string.Format(CultureInfo.CurrentCulture, fallbackItemSearchPageTitleCompositeFormat, query); - Icon = Icons.FileExplorerIcon; - Command = indexerPage; - MoreCommands = null; - DataPackage = null; - return; } - catch + + var thumbnailStream = RandomAccessStreamReference.CreateFromStream(stream); + if (ct.IsCancellationRequested) { - Title = string.Empty; - Subtitle = string.Empty; - Icon = null; - Command = new NoOpCommand(); - MoreCommands = null; - DataPackage = null; + return; } + + var data = new IconData(thumbnailStream); + UpdateIconForCurrentQuery(new IconInfo(data), ct); + } + catch + { + // ignore - keep default icon + UpdateIconForCurrentQuery(Icons.FileExplorerIcon, ct); + } + } + + private bool ClearResultForCurrentQuery(CancellationToken ct) + { + return UpdateResultForCurrentQuery(string.Empty, string.Empty, Icons.FileExplorerIcon, BaseCommandWithId, null, null, false, ct); + } + + private bool UpdateResultForCurrentQuery(IndexerListItem listItem, bool skipIcon, CancellationToken ct) + { + return UpdateResultForCurrentQuery( + listItem.Title, + listItem.Subtitle, + listItem.Icon, + listItem.Command, + listItem.MoreCommands, + DataPackageHelper.CreateDataPackageForPath(listItem, listItem.FilePath), + skipIcon, + ct); + } + + private bool UpdateResultForCurrentQuery(string title, string subtitle, IIconInfo? iconInfo, ICommand? command, IContextItem[]? moreCommands, DataPackage? dataPackage, bool skipIcon, CancellationToken ct) + { + lock (_resultLock) + { + if (ct.IsCancellationRequested) + { + return false; + } + + Title = title; + Subtitle = subtitle; + if (!skipIcon) + { + Icon = iconInfo!; + } + + MoreCommands = moreCommands!; + DataPackage = dataPackage; + Command = command; + return true; + } + } + + private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct) + { + lock (_resultLock) + { + if (ct.IsCancellationRequested) + { + return; + } + + Icon = icon; } } public void Dispose() { - _searchEngine.Dispose(); + _currentQueryCts?.Cancel(); + _currentQueryCts?.Dispose(); GC.SuppressFinalize(this); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs index 65d18a0e2a..a4e7873189 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#nullable enable + using System; using System.IO; using System.Threading.Tasks; @@ -14,51 +16,83 @@ namespace Microsoft.CmdPal.Ext.Indexer.Helpers; internal static class DataPackageHelper { - public static DataPackage CreateDataPackageForPath(ICommandItem listItem, string path) + public static DataPackage? CreateDataPackageForPath(ICommandItem listItem, string? path) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrWhiteSpace(path)) { return null; } - var dataPackage = new DataPackage(); - dataPackage.SetText(path); - _ = dataPackage.TrySetStorageItemsAsync(path); - dataPackage.Properties.Title = listItem.Title; - dataPackage.Properties.Description = listItem.Subtitle; - dataPackage.RequestedOperation = DataPackageOperation.Copy; + // Capture now; don't rely on listItem still being valid later. + var title = listItem.Title; + var description = listItem.Subtitle; + var capturedPath = path; + + var dataPackage = new DataPackage + { + RequestedOperation = DataPackageOperation.Copy, + Properties = + { + Title = title, + Description = description, + }, + }; + + // Cheap + immediate. + dataPackage.SetText(capturedPath); + + // Expensive + only computed if the consumer asks for StorageItems. + dataPackage.SetDataProvider(StandardDataFormats.StorageItems, async void (request) => + { + var deferral = request.GetDeferral(); + try + { + var items = await TryGetStorageItemAsync(capturedPath).ConfigureAwait(false); + if (items is not null) + { + request.SetData(items); + } + + // If null: just don't provide StorageItems. Text still works. + } + catch + { + // Swallow: better to provide partial data (text) than fail the whole package. + } + finally + { + deferral.Complete(); + } + }); + return dataPackage; } - public static async Task TrySetStorageItemsAsync(this DataPackage dataPackage, string filePath) + private static async Task TryGetStorageItemAsync(string filePath) { try { if (File.Exists(filePath)) { var file = await StorageFile.GetFileFromPathAsync(filePath); - dataPackage.SetStorageItems([file]); - return true; + return [file]; } if (Directory.Exists(filePath)) { var folder = await StorageFolder.GetFolderFromPathAsync(filePath); - dataPackage.SetStorageItems([folder]); - return true; + return [folder]; } - // nothing there - return false; + return null; } catch (UnauthorizedAccessException) { - // Access denied – skip or report, but don't crash - return false; + return null; } - catch (Exception) + catch { - return false; + return null; } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs index 6b85834bb8..6dd3137dbb 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -17,66 +18,25 @@ namespace Microsoft.CmdPal.Ext.Indexer.Indexer; internal sealed partial class SearchQuery : IDisposable { - private readonly Lock _lockObject = new(); // Lock object for synchronization - private readonly DBPROPIDSET dbPropIdSet; + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Indexing Service constant")] + private const int QUERY_E_ALLNOISE = unchecked((int)0x80041605); - private uint reuseWhereID; - private EventWaitHandle queryCompletedEvent; - private Timer queryTpTimer; - private IRowset currentRowset; - private IRowset reuseRowset; + private readonly Lock _lockObject = new(); - public uint Cookie { get; set; } + private IRowset _currentRowset; + + public QueryState State { get; private set; } = QueryState.NotStarted; + + private int? LastHResult { get; set; } + + private string LastErrorMessage { get; set; } + + public uint Cookie { get; private set; } public string SearchText { get; private set; } public ConcurrentQueue SearchResults { get; private set; } = []; - public SearchQuery() - { - dbPropIdSet = new DBPROPIDSET - { - rgPropertyIDs = Marshal.AllocCoTaskMem(sizeof(uint)), // Allocate memory for the property ID array - cPropertyIDs = 1, - guidPropertySet = new Guid("AA6EE6B0-E828-11D0-B23E-00AA0047FC01"), // DBPROPSET_MSIDXS_ROWSETEXT, - }; - - // Copy the property ID into the allocated memory - Marshal.WriteInt32(dbPropIdSet.rgPropertyIDs, 8); // MSIDXSPROP_WHEREID - - Init(); - } - - private void Init() - { - // Create all the objects we will want cached - try - { - queryTpTimer = new Timer(QueryTimerCallback, this, Timeout.Infinite, Timeout.Infinite); - if (queryTpTimer is null) - { - Logger.LogError("Failed to create query timer"); - return; - } - - queryCompletedEvent = new EventWaitHandle(false, EventResetMode.ManualReset); - if (queryCompletedEvent is null) - { - Logger.LogError("Failed to create query completed event"); - return; - } - - // Execute a synchronous query on file items to prime the index and keep that handle around - PrimeIndexAndCacheWhereId(); - } - catch (Exception ex) - { - Logger.LogError("Exception at SearchUXQueryHelper Init", ex); - } - } - - public void WaitForQueryCompletedEvent() => queryCompletedEvent.WaitOne(); - public void CancelOutstandingQueries() { Logger.LogDebug("Cancel query " + SearchText); @@ -84,14 +44,7 @@ internal sealed partial class SearchQuery : IDisposable // Are we currently doing work? If so, let's cancel lock (_lockObject) { - if (queryTpTimer is not null) - { - queryTpTimer.Change(Timeout.Infinite, Timeout.Infinite); - queryTpTimer.Dispose(); - queryTpTimer = null; - } - - Init(); + State = QueryState.Cancelled; } } @@ -102,40 +55,32 @@ internal sealed partial class SearchQuery : IDisposable ExecuteSyncInternal(); } - public static void QueryTimerCallback(object state) - { - var pQueryHelper = (SearchQuery)state; - pQueryHelper.ExecuteSyncInternal(); - } - private void ExecuteSyncInternal() { lock (_lockObject) { - var queryStr = QueryStringBuilder.GenerateQuery(SearchText, reuseWhereID); + State = QueryState.Running; + LastHResult = null; + LastErrorMessage = null; + + var queryStr = QueryStringBuilder.GenerateQuery(SearchText); try { - // We need to generate a search query string with the search text the user entered above - if (currentRowset is not null) - { - // We have a previous rowset, this means the user is typing and we should store this - // recapture the where ID from this so the next ExecuteSync call will be faster - reuseRowset = currentRowset; - reuseWhereID = GetReuseWhereId(reuseRowset); - } - - currentRowset = ExecuteCommand(queryStr); + var result = ExecuteCommand(queryStr); + _currentRowset = result.Rowset; + State = result.State; + LastHResult = result.HResult; + LastErrorMessage = result.ErrorMessage; SearchResults.Clear(); } catch (Exception ex) { + State = QueryState.ExecuteFailed; + LastHResult = ex.HResult; + LastErrorMessage = ex.Message; Logger.LogError("Error executing query", ex); } - finally - { - queryCompletedEvent.Set(); - } } } @@ -170,31 +115,68 @@ internal sealed partial class SearchQuery : IDisposable public bool FetchRows(int offset, int limit) { - if (currentRowset is null) + if (_currentRowset is null) { - Logger.LogError("No rowset to fetch rows from"); + var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"; + + switch (State) + { + case QueryState.NoResults: + case QueryState.AllNoise: + Logger.LogDebug(message); + break; + case QueryState.NotStarted: + case QueryState.Cancelled: + case QueryState.Running: + Logger.LogInfo(message); + break; + default: + Logger.LogError(message); + break; + } + return false; } - IGetRow getRow = null; + IGetRow getRow; try { - getRow = (IGetRow)currentRowset; + getRow = (IGetRow)_currentRowset; } - catch (Exception) + catch (Exception ex) { - Logger.LogInfo("Reset the current rowset"); + Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"); + Logger.LogError("Failed to cast current rowset to IGetRow", ex); + ExecuteSyncInternal(); - getRow = (IGetRow)currentRowset; + + if (_currentRowset is null) + { + var message = $"Failed to reset rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"; + + switch (State) + { + case QueryState.NoResults: + case QueryState.AllNoise: + Logger.LogDebug(message); + break; + default: + Logger.LogError(message); + break; + } + + return false; + } + + getRow = (IGetRow)_currentRowset; } - uint rowCountReturned; var prghRows = IntPtr.Zero; try { - currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out rowCountReturned, out prghRows); + _currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out var rowCountReturned, out prghRows); if (rowCountReturned == 0) { @@ -215,7 +197,7 @@ internal sealed partial class SearchQuery : IDisposable } } - currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null); + _currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null); Marshal.FreeCoTaskMem(prghRows); prghRows = IntPtr.Zero; @@ -236,141 +218,91 @@ internal sealed partial class SearchQuery : IDisposable } } - private void PrimeIndexAndCacheWhereId() - { - var queryStr = QueryStringBuilder.GeneratePrimingQuery(); - var rowset = ExecuteCommand(queryStr); - if (rowset is not null) - { - reuseRowset = rowset; - reuseWhereID = GetReuseWhereId(reuseRowset); - } - } - - private unsafe IRowset ExecuteCommand(string queryStr) + private static ExecuteCommandResult ExecuteCommand(string queryStr) { if (string.IsNullOrEmpty(queryStr)) { - return null; + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: null, ErrorMessage: "Query string was empty."); } try { - var session = (IDBCreateSession)DataSourceManager.GetDataSource(); + var dataSource = DataSourceManager.GetDataSource(); + if (dataSource is null) + { + Logger.LogError("GetDataSource returned null"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.NullDataSource, HResult: null, ErrorMessage: "GetDataSource returned null."); + } + + var session = (IDBCreateSession)dataSource; var guid = typeof(IDBCreateCommand).GUID; session.CreateSession(IntPtr.Zero, ref guid, out var ppDBSession); if (ppDBSession is null) { Logger.LogError("CreateSession failed"); - return null; + return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateSessionFailed, HResult: null, ErrorMessage: "CreateSession returned null session."); } var createCommand = (IDBCreateCommand)ppDBSession; guid = typeof(ICommandText).GUID; - createCommand.CreateCommand(IntPtr.Zero, ref guid, out ICommandText commandText); + createCommand.CreateCommand(IntPtr.Zero, ref guid, out var commandText); if (commandText is null) { Logger.LogError("Failed to get ICommandText interface"); - return null; + return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateCommandFailed, HResult: null, ErrorMessage: "CreateCommand returned null command."); } var riid = NativeHelpers.OleDb.DbGuidDefault; - var irowSetRiid = typeof(IRowset).GUID; commandText.SetCommandText(ref riid, queryStr); - commandText.Execute(null, ref irowSetRiid, null, out var pcRowsAffected, out var rowsetPointer); + commandText.Execute(null, ref irowSetRiid, null, out _, out var rowsetPointer); - return rowsetPointer; + return rowsetPointer is null + ? new ExecuteCommandResult(Rowset: null, State: QueryState.NoResults, HResult: null, ErrorMessage: null) + : new ExecuteCommandResult(Rowset: rowsetPointer, State: QueryState.Completed, HResult: null, ErrorMessage: null); + } + catch (COMException ex) when (ex.HResult == QUERY_E_ALLNOISE) + { + Logger.LogDebug($"Query returned all noise, no results. ({queryStr})"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.AllNoise, HResult: ex.HResult, ErrorMessage: ex.Message); + } + catch (COMException ex) + { + Logger.LogError($"Unexpected COM error for query '{queryStr}'.", ex); + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message); } catch (Exception ex) { - Logger.LogError("Unexpected error.", ex); - return null; + Logger.LogError($"Unexpected error for query '{queryStr}'.", ex); + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message); } } - private unsafe DBPROP? GetPropset(IRowsetInfo rowsetInfo) - { - var prgPropSetsPtr = IntPtr.Zero; - - try - { - ulong cPropertySets; - var res = rowsetInfo.GetProperties(1, [dbPropIdSet], out cPropertySets, out prgPropSetsPtr); - if (res != 0) - { - Logger.LogError($"Error getting properties: {res}"); - return null; - } - - if (cPropertySets == 0 || prgPropSetsPtr == IntPtr.Zero) - { - Logger.LogError("No property sets returned"); - return null; - } - - var firstPropSetPtr = (DBPROPSET*)prgPropSetsPtr.ToInt64(); - var propSet = *firstPropSetPtr; - if (propSet.cProperties == 0 || propSet.rgProperties == IntPtr.Zero) - { - return null; - } - - var propPtr = (DBPROP*)propSet.rgProperties.ToInt64(); - return *propPtr; - } - catch (Exception ex) - { - Logger.LogError($"Exception occurred while getting properties,", ex); - return null; - } - finally - { - // Free the property sets pointer returned by GetProperties, if necessary - if (prgPropSetsPtr != IntPtr.Zero) - { - Marshal.FreeCoTaskMem(prgPropSetsPtr); - } - } - } - - private uint GetReuseWhereId(IRowset rowset) - { - var rowsetInfo = (IRowsetInfo)rowset; - - if (rowsetInfo is null) - { - return 0; - } - - var prop = GetPropset(rowsetInfo); - if (prop is null) - { - return 0; - } - - if (prop?.vValue.VarType == VarEnum.VT_UI4) - { - var value = prop?.vValue._ulong; - return (uint)value; - } - - return 0; - } - public void Dispose() { CancelOutstandingQueries(); - - // Free the allocated memory for rgPropertyIDs - if (dbPropIdSet.rgPropertyIDs != IntPtr.Zero) - { - Marshal.FreeCoTaskMem(dbPropIdSet.rgPropertyIDs); - } - - queryCompletedEvent?.Dispose(); } + + internal enum QueryState + { + NotStarted = 0, + Running, + Completed, + NoResults, + AllNoise, + NullDataSource, + CreateSessionFailed, + CreateCommandFailed, + ExecuteFailed, + Cancelled, + } + + private readonly record struct ExecuteCommandResult( + IRowset Rowset, + QueryState State, + int? HResult, + string ErrorMessage); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs index 068ea08750..52b130ee68 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; -using System.Globalization; using System.Runtime.CompilerServices; using ManagedCommon; using ManagedCsWin32; @@ -11,20 +10,16 @@ using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; -internal sealed partial class QueryStringBuilder +internal static class QueryStringBuilder { private const string Properties = "System.ItemUrl, System.ItemNameDisplay, path, System.Search.EntryID, System.Kind, System.KindText"; private const string SystemIndex = "SystemIndex"; private const string ScopeFileConditions = "SCOPE='file:'"; private const string OrderConditions = "System.DateModified DESC"; - private const string SelectQueryWithScope = "SELECT " + Properties + " FROM " + SystemIndex + " WHERE (" + ScopeFileConditions + ")"; - private const string SelectQueryWithScopeAndOrderConditions = SelectQueryWithScope + " ORDER BY " + OrderConditions; private static ISearchQueryHelper queryHelper; - public static string GeneratePrimingQuery() => SelectQueryWithScopeAndOrderConditions; - - public static string GenerateQuery(string searchText, uint whereId) + public static string GenerateQuery(string searchText) { if (queryHelper is null) { @@ -40,7 +35,7 @@ internal sealed partial class QueryStringBuilder throw; } - ISearchCatalogManager catalogManager = searchManager.GetCatalog(SystemIndex); + var catalogManager = searchManager.GetCatalog(SystemIndex); if (catalogManager is null) { throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}"); @@ -57,7 +52,7 @@ internal sealed partial class QueryStringBuilder queryHelper.SetQuerySorting(OrderConditions); } - queryHelper.SetQueryWhereRestrictions("AND " + ScopeFileConditions + "AND ReuseWhere(" + whereId.ToString(CultureInfo.InvariantCulture) + ")"); + queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}"); return queryHelper.GenerateSQLFromUserQuery(searchText); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs index 41abc0b018..f355db27bc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs @@ -2,10 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#nullable enable + using System; using System.Collections.Generic; using System.Globalization; using System.Text.Encodings.Web; +using System.Threading; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Indexer.Indexer; using Microsoft.CmdPal.Ext.Indexer.Properties; @@ -16,18 +19,25 @@ namespace Microsoft.CmdPal.Ext.Indexer; internal sealed partial class IndexerPage : DynamicListPage, IDisposable { + // Cookie to identify our queries; since we replace the SearchEngine on each search, + // this can be a constant. + private const uint HardQueryCookie = 10; + private readonly List _indexerListItems = []; - private readonly SearchEngine _searchEngine; - private readonly bool disposeSearchEngine = true; + private readonly Lock _searchLock = new(); - private uint _queryCookie; - - private string initialQuery = string.Empty; + private SearchEngine? _searchEngine; + private CancellationTokenSource? _searchCts; + private string _initialQuery = string.Empty; private bool _isEmptyQuery = true; - private CommandItem _noSearchEmptyContent; - private CommandItem _nothingFoundEmptyContent; + private CommandItem? _noSearchEmptyContent; + private CommandItem? _nothingFoundEmptyContent; + + private bool _deferredLoad; + + public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!; public IndexerPage() { @@ -35,8 +45,8 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable Icon = Icons.FileExplorerIcon; Name = Resources.Indexer_Title; PlaceholderText = Resources.Indexer_PlaceholderText; + _searchEngine = new(); - _queryCookie = 10; var filters = new SearchFilters(); filters.PropChanged += Filters_PropChanged; @@ -45,22 +55,23 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable CreateEmptyContent(); } - public IndexerPage(string query, SearchEngine searchEngine, uint queryCookie, IList firstPageData) + public IndexerPage(string query) { Icon = Icons.FileExplorerIcon; Name = Resources.Indexer_Title; - _searchEngine = searchEngine; - _queryCookie = queryCookie; - _indexerListItems.AddRange(firstPageData); - initialQuery = query; + + _searchEngine = new(); + + _initialQuery = query; SearchText = query; - disposeSearchEngine = false; var filters = new SearchFilters(); filters.PropChanged += Filters_PropChanged; Filters = filters; CreateEmptyContent(); + IsLoading = true; + _deferredLoad = true; } private void CreateEmptyContent() @@ -95,8 +106,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable ShellHelpers.OpenInShell(command); } - public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent : _nothingFoundEmptyContent; - private void Filters_PropChanged(object sender, IPropChangedEventArgs args) { PerformSearch(SearchText); @@ -104,35 +113,31 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable public override void UpdateSearchText(string oldSearch, string newSearch) { - if (oldSearch != newSearch && newSearch != initialQuery) + if (oldSearch != newSearch && newSearch != _initialQuery) { PerformSearch(newSearch); } } - private void PerformSearch(string newSearch) + public override IListItem[] GetItems() { - var actualSearch = FullSearchString(newSearch); - _ = Task.Run(() => + if (_deferredLoad) { - _isEmptyQuery = string.IsNullOrWhiteSpace(actualSearch); - Query(actualSearch); - LoadMore(); - OnPropertyChanged(nameof(EmptyContent)); - initialQuery = null; - }); - } + PerformSearch(_initialQuery); + _deferredLoad = false; + } - public override IListItem[] GetItems() => [.. _indexerListItems]; + return [.. _indexerListItems]; + } private string FullSearchString(string query) { - switch (Filters.CurrentFilterId) + switch (Filters?.CurrentFilterId) { case "folders": - return $"{query} kind:folders"; + return $"System.Kind:folders {query}"; case "files": - return $"{query} kind:NOT folders"; + return $"System.Kind:NOT folders {query}"; case "all": default: return query; @@ -141,28 +146,139 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable public override void LoadMore() { + var ct = Volatile.Read(ref _searchCts)?.Token; + IsLoading = true; - var results = _searchEngine.FetchItems(_indexerListItems.Count, 20, _queryCookie, out var hasMore); - _indexerListItems.AddRange(results); - HasMoreItems = hasMore; - IsLoading = false; - RaiseItemsChanged(_indexerListItems.Count); + + var hasMore = false; + SearchEngine? searchEngine; + int offset; + + lock (_searchLock) + { + searchEngine = _searchEngine; + offset = _indexerListItems.Count; + } + + var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? []; + + if (ct?.IsCancellationRequested == true) + { + IsLoading = false; + return; + } + + lock (_searchLock) + { + if (ct?.IsCancellationRequested == true) + { + IsLoading = false; + return; + } + + _indexerListItems.AddRange(results); + HasMoreItems = hasMore; + IsLoading = false; + RaiseItemsChanged(_indexerListItems.Count); + } } private void Query(string query) { - ++_queryCookie; - _indexerListItems.Clear(); + lock (_searchLock) + { + _indexerListItems.Clear(); + _searchEngine?.Query(query, queryCookie: HardQueryCookie); + } + } - _searchEngine.Query(query, _queryCookie); + private void ReplaceSearchEngine(SearchEngine newSearchEngine) + { + SearchEngine? oldEngine; + + lock (_searchLock) + { + oldEngine = _searchEngine; + _searchEngine = newSearchEngine; + } + + oldEngine?.Dispose(); + } + + private void PerformSearch(string newSearch) + { + var actualSearch = FullSearchString(newSearch); + + var newCts = new CancellationTokenSource(); + var oldCts = Interlocked.Exchange(ref _searchCts, newCts); + oldCts?.Cancel(); + oldCts?.Dispose(); + + var ct = newCts.Token; + + _ = Task.Run( + () => + { + ct.ThrowIfCancellationRequested(); + + lock (_searchLock) + { + // If the user hasn't provided any base query text, results should be empty + // regardless of the currently selected filter. + _isEmptyQuery = string.IsNullOrWhiteSpace(newSearch); + + if (_isEmptyQuery) + { + _indexerListItems.Clear(); + HasMoreItems = false; + IsLoading = false; + RaiseItemsChanged(0); + OnPropertyChanged(nameof(EmptyContent)); + _initialQuery = string.Empty; + return; + } + + // Track the most recent query we initiated, so UpdateSearchText doesn't + // spuriously suppress a search when SearchText gets set programmatically. + _initialQuery = newSearch; + } + + ct.ThrowIfCancellationRequested(); + ReplaceSearchEngine(new SearchEngine()); + + ct.ThrowIfCancellationRequested(); + Query(actualSearch); + + ct.ThrowIfCancellationRequested(); + LoadMore(); + + ct.ThrowIfCancellationRequested(); + + lock (_searchLock) + { + OnPropertyChanged(nameof(EmptyContent)); + } + }, + ct); } public void Dispose() { - if (disposeSearchEngine) + var cts = Interlocked.Exchange(ref _searchCts, null); + cts?.Cancel(); + cts?.Dispose(); + + SearchEngine? searchEngine; + + lock (_searchLock) { - _searchEngine.Dispose(); - GC.SuppressFinalize(this); + searchEngine = _searchEngine; + _searchEngine = null; + _indexerListItems.Clear(); } + + searchEngine?.Dispose(); + + GC.SuppressFinalize(this); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs index 469c5faf61..1dfde65a28 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -177,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } } + /// + /// Looks up a localized string similar to The query matches multiple items. + /// + internal static string Indexer_Fallback_MultipleResults_Subtitle { + get { + return ResourceManager.GetString("Indexer_Fallback_MultipleResults_Subtitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Search for "{0}" in files. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx index 8f5f760137..6c7e6483c9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx @@ -211,4 +211,7 @@ You can try searching all files on this PC or adjust your indexing settings. Failed to launch Peek + + The query matches multiple items + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs index eb1ca563b4..15fff442c1 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#nullable enable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -16,15 +18,20 @@ namespace Microsoft.CmdPal.Ext.Indexer; public sealed partial class SearchEngine : IDisposable { - private SearchQuery _searchQuery = new(); + private SearchQuery? _searchQuery = new(); public void Query(string query, uint queryCookie) { - // _indexerListItems.Clear(); - _searchQuery.SearchResults.Clear(); - _searchQuery.CancelOutstandingQueries(); + var searchQuery = _searchQuery; + if (searchQuery is null) + { + return; + } - if (query == string.Empty) + searchQuery.SearchResults.Clear(); + searchQuery.CancelOutstandingQueries(); + + if (string.IsNullOrWhiteSpace(query)) { return; } @@ -32,64 +39,74 @@ public sealed partial class SearchEngine : IDisposable Stopwatch stopwatch = new(); stopwatch.Start(); - _searchQuery.Execute(query, queryCookie); + searchQuery.Execute(query, queryCookie); stopwatch.Stop(); Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\""); } - public IList FetchItems(int offset, int limit, uint queryCookie, out bool hasMore) + public IList FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false) { hasMore = false; - var results = new List(); - if (_searchQuery is not null) + + var searchQuery = _searchQuery; + if (searchQuery is null) { - var cookie = _searchQuery.Cookie; - if (cookie == queryCookie) - { - var index = 0; - SearchResult result; - - // var hasMoreItems = _searchQuery.FetchRows(_indexerListItems.Count, limit); - var hasMoreItems = _searchQuery.FetchRows(offset, limit); - - while (!_searchQuery.SearchResults.IsEmpty && _searchQuery.SearchResults.TryDequeue(out result) && ++index <= limit) - { - IconInfo icon = null; - try - { - var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result; - if (stream is not null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - icon = new IconInfo(data, data); - } - } - catch (Exception ex) - { - Logger.LogError("Failed to get the icon.", ex); - } - - results.Add(new IndexerListItem(new IndexerItem - { - FileName = result.ItemDisplayName, - FullPath = result.LaunchUri, - }) - { - Icon = icon, - }); - } - - hasMore = hasMoreItems; - } + return []; } + var cookie = searchQuery.Cookie; + if (cookie != queryCookie) + { + return []; + } + + var results = new List(); + var index = 0; + var hasMoreItems = searchQuery.FetchRows(offset, limit); + + while (!searchQuery.SearchResults.IsEmpty && searchQuery.SearchResults.TryDequeue(out var result) && ++index <= limit) + { + var indexerListItem = new IndexerListItem(new IndexerItem + { + FileName = result.ItemDisplayName, + FullPath = result.LaunchUri, + }); + + if (!noIcons) + { + IconInfo? icon = null; + try + { + var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result; + if (stream is not null) + { + var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); + icon = new IconInfo(data, data); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get the icon.", ex); + } + + indexerListItem.Icon = icon; + } + + results.Add(indexerListItem); + } + + hasMore = hasMoreItems; return results; } public void Dispose() { + var searchQuery = _searchQuery; _searchQuery = null; + + searchQuery?.Dispose(); + GC.SuppressFinalize(this); } }