mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-05 02:36:19 +02:00
CmdPal: Make Indexer great again - part 1 - hotfix (#44729)
## 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. <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Related to: #44728 - [x] Closes: #44731 - [x] Closes: #44732 - [x] Closes: #44743 <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **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 <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed
This commit is contained in:
@@ -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<string, bool> _suppressCallback;
|
||||
private CancellationTokenSource? _currentQueryCts;
|
||||
private Func<string, bool>? _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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> TrySetStorageItemsAsync(this DataPackage dataPackage, string filePath)
|
||||
private static async Task<IStorageItem[]?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SearchResult> 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IListItem> _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<IListItem> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The query matches multiple items.
|
||||
/// </summary>
|
||||
internal static string Indexer_Fallback_MultipleResults_Subtitle {
|
||||
get {
|
||||
return ResourceManager.GetString("Indexer_Fallback_MultipleResults_Subtitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Search for "{0}" in files.
|
||||
/// </summary>
|
||||
|
||||
@@ -211,4 +211,7 @@ You can try searching all files on this PC or adjust your indexing settings.</va
|
||||
<data name="Indexer_Command_Peek_Failed" xml:space="preserve">
|
||||
<value>Failed to launch Peek</value>
|
||||
</data>
|
||||
<data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve">
|
||||
<value>The query matches multiple items</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore)
|
||||
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false)
|
||||
{
|
||||
hasMore = false;
|
||||
var results = new List<IListItem>();
|
||||
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<IListItem>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user