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:
Jiří Polášek
2026-02-02 18:23:34 +01:00
committed by GitHub
parent dca532cf4b
commit 4d1f92199c
9 changed files with 614 additions and 406 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 &quot;{0}&quot; in files.
/// </summary>

View File

@@ -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>

View File

@@ -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);
}
}