Skip to content

Commit 90ab03f

Browse files
committed
Improve CmdPal indexer wildcard search and Windows Search notices
- Improves simple free-text Windows Search queries with implicit filename broadening while preserving structured AQS input. - Adds resilient fallback behavior for noisy or punctuation-heavy searches by retrying with literal filename matching. - Surfaces Windows Search availability and indexing-status notices in the indexer page and fallback item. - Tightens production logging for datasource, query-execution, and catalog-status failures. - Documents the query-handling heuristics and the paging/notice invariants for future maintainers.
1 parent 0089de3 commit 90ab03f

20 files changed

+1253
-58
lines changed

PowerToys.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,10 @@
319319
<Platform Solution="*|ARM64" Project="ARM64" />
320320
<Platform Solution="*|x64" Project="x64" />
321321
</Project>
322+
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Indexer.UnitTests/Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj">
323+
<Platform Solution="*|ARM64" Project="ARM64" />
324+
<Platform Solution="*|x64" Project="x64" />
325+
</Project>
322326
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj">
323327
<Platform Solution="*|ARM64" Project="ARM64" />
324328
<Platform Solution="*|x64" Project="x64" />

src/modules/cmdpal/CommandPalette.slnf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
2121
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
2222
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj",
23+
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Indexer.UnitTests\\Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj",
2324
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Registry.UnitTests\\Microsoft.CmdPal.Ext.Registry.UnitTests.csproj",
2425
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj",
2526
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Shell.UnitTests\\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj",
@@ -54,4 +55,4 @@
5455
"src\\modules\\cmdpal\\extensionsdk\\Microsoft.CommandPalette.Extensions\\Microsoft.CommandPalette.Extensions.vcxproj"
5556
]
5657
}
57-
}
58+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
6+
using Microsoft.VisualStudio.TestTools.UnitTesting;
7+
8+
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
9+
10+
[TestClass]
11+
public class ImplicitWildcardQueryBuilderTests
12+
{
13+
[DataTestMethod]
14+
[DataRow("term", null, "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
15+
[DataRow("term Kind:Folder", "Kind:Folder", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
16+
[DataRow("System.Kind:folders term", "System.Kind:folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
17+
[DataRow("System.Kind:NOT folders term", "System.Kind:NOT folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
18+
[DataRow("\"two words\"", null, "((CONTAINS(System.ItemNameDisplay, '\"two words\"') OR CONTAINS(System.ItemNameDisplay, '\"two words*\"') OR CONTAINS(System.ItemNameDisplay, '\"two\" AND \"words\"') OR CONTAINS(System.ItemNameDisplay, '\"two*\" AND \"words*\"')) OR System.FileName LIKE '%two words%')", "System.FileName LIKE '%two words%'")]
19+
[DataRow("foo bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
20+
[DataRow("foo-bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR System.FileName LIKE '%foo-bar%')", "System.FileName LIKE '%foo-bar%'")]
21+
[DataRow("foo & bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
22+
[DataRow("tonträger", null, "((CONTAINS(System.ItemNameDisplay, '\"tonträger\"') OR CONTAINS(System.ItemNameDisplay, '\"tonträger*\"')) OR System.FileName LIKE '%tonträger%')", "System.FileName LIKE '%tonträger%'")]
23+
[DataRow("O'Hara", null, "((CONTAINS(System.ItemNameDisplay, '\"Hara\"') OR CONTAINS(System.ItemNameDisplay, '\"Hara*\"')) OR System.FileName LIKE '%O''Hara%')", "System.FileName LIKE '%O''Hara%'")]
24+
[DataRow("AT&T", null, "System.FileName LIKE '%AT&T%'", null)]
25+
[DataRow("file_100%", null, "((CONTAINS(System.ItemNameDisplay, '\"file 100\"') OR CONTAINS(System.ItemNameDisplay, '\"file 100*\"') OR CONTAINS(System.ItemNameDisplay, '\"file\" AND \"100\"') OR CONTAINS(System.ItemNameDisplay, '\"file*\" AND \"100*\"')) OR System.FileName LIKE '%file[_]100[%]%')", "System.FileName LIKE '%file[_]100[%]%'")]
26+
public void BuildExpandedQuery_BuildsExpectedRestrictions(string query, string expectedStructuredSearchText, string expectedPrimaryClause, string expectedFallbackClause)
27+
{
28+
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
29+
30+
Assert.AreEqual(expectedStructuredSearchText, expandedQuery.StructuredSearchText);
31+
Assert.AreEqual(expectedPrimaryClause, expandedQuery.PrimaryRestriction);
32+
Assert.AreEqual(expectedFallbackClause, expandedQuery.FallbackRestriction);
33+
}
34+
35+
[TestMethod]
36+
public void BuildExpandedQuery_PreservesBracketWrappedTermAsLiteralOnly()
37+
{
38+
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("[red]");
39+
40+
Assert.AreEqual("System.FileName LIKE '%[[]red[]]%'", expandedQuery.PrimaryRestriction);
41+
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
42+
}
43+
44+
[TestMethod]
45+
public void BuildExpandedQuery_TreatsSinglePercentAsLiteralCharacter()
46+
{
47+
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("%");
48+
49+
Assert.AreEqual("System.FileName LIKE '%[%]%'", expandedQuery.PrimaryRestriction);
50+
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
51+
}
52+
53+
[TestMethod]
54+
public void BuildExpandedQuery_TreatsSingleUnderscoreAsLiteralCharacter()
55+
{
56+
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("_");
57+
58+
Assert.AreEqual("System.FileName LIKE '%[_]%'", expandedQuery.PrimaryRestriction);
59+
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
60+
}
61+
62+
[DataTestMethod]
63+
[DataRow("kind:folder")]
64+
[DataRow("name:term")]
65+
[DataRow("name: term")]
66+
[DataRow("name:\"two words\"")]
67+
[DataRow("*term*")]
68+
[DataRow("C:\\Users")]
69+
[DataRow("System.Kind:folders")]
70+
[DataRow("kind:folder AND term")]
71+
public void BuildExpandedQuery_DoesNotBroadenStructuredOrExplicitQueries(string query)
72+
{
73+
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
74+
75+
Assert.IsFalse(expandedQuery.HasPrimaryRestriction);
76+
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
77+
}
78+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<!-- Look at Directory.Build.props in root for common stuff as well -->
3+
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
4+
5+
<PropertyGroup>
6+
<IsPackable>false</IsPackable>
7+
<IsTestProject>true</IsTestProject>
8+
<RootNamespace>Microsoft.CmdPal.Ext.Indexer.UnitTests</RootNamespace>
9+
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
10+
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
11+
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="MSTest" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
20+
</ItemGroup>
21+
</Project>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using Microsoft.CmdPal.Ext.Indexer.Indexer;
6+
using Microsoft.CmdPal.Ext.Indexer.Properties;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
9+
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
10+
11+
[TestClass]
12+
public class SearchNoticeInfoBuilderTests
13+
{
14+
[DataTestMethod]
15+
[DataRow((int)SearchQuery.QueryState.NullDataSource)]
16+
[DataRow((int)SearchQuery.QueryState.CreateSessionFailed)]
17+
[DataRow((int)SearchQuery.QueryState.CreateCommandFailed)]
18+
public void FromQueryStatus_ReturnsUnavailableNotice_ForInfrastructureFailures(int stateValue)
19+
{
20+
var state = (SearchQuery.QueryState)stateValue;
21+
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, "failure"));
22+
23+
Assert.IsNotNull(notice);
24+
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
25+
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessageTip, notice.Value.Subtitle);
26+
}
27+
28+
[TestMethod]
29+
public void FromQueryStatus_ReturnsUnavailableNotice_ForRpcFailures()
30+
{
31+
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
32+
new SearchQuery.SearchExecutionStatus(
33+
SearchQuery.QueryState.ExecuteFailed,
34+
unchecked((int)0x800706BA),
35+
"RPC server unavailable"));
36+
37+
Assert.IsNotNull(notice);
38+
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
39+
}
40+
41+
[TestMethod]
42+
public void FromQueryStatus_ReturnsGenericFailureNotice_ForUnexpectedFailures()
43+
{
44+
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
45+
new SearchQuery.SearchExecutionStatus(
46+
SearchQuery.QueryState.ExecuteFailed,
47+
unchecked((int)0x80004005),
48+
"unexpected"));
49+
50+
Assert.IsNotNull(notice);
51+
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, notice.Value.Title);
52+
Assert.AreEqual(Resources.Indexer_SearchFailedMessageTip, notice.Value.Subtitle);
53+
}
54+
55+
[DataTestMethod]
56+
[DataRow((int)SearchQuery.QueryState.Completed)]
57+
[DataRow((int)SearchQuery.QueryState.NoResults)]
58+
[DataRow((int)SearchQuery.QueryState.AllNoise)]
59+
public void FromQueryStatus_ReturnsNull_ForNonFailureStates(int stateValue)
60+
{
61+
var state = (SearchQuery.QueryState)stateValue;
62+
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, null));
63+
64+
Assert.IsNull(notice);
65+
}
66+
67+
[TestMethod]
68+
public void FromCatalogStatus_ReturnsIndexingNotice_WhenItemsArePending()
69+
{
70+
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(42, null));
71+
72+
Assert.IsNotNull(notice);
73+
Assert.AreEqual(Resources.Indexer_SearchIndexingMessage, notice.Value.Title);
74+
StringAssert.Contains(notice.Value.Subtitle, "42");
75+
}
76+
77+
[TestMethod]
78+
public void FromCatalogStatus_ReturnsNull_WhenStatusReadFails()
79+
{
80+
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, unchecked((int)0x800706BA)));
81+
82+
Assert.IsNull(notice);
83+
}
84+
85+
[TestMethod]
86+
public void FromCatalogStatus_ReturnsNull_WhenIndexingIsIdle()
87+
{
88+
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, null));
89+
90+
Assert.IsNull(notice);
91+
}
92+
}

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.Threading.Tasks;
1313
using Microsoft.CmdPal.Ext.Indexer.Data;
1414
using Microsoft.CmdPal.Ext.Indexer.Helpers;
15+
using Microsoft.CmdPal.Ext.Indexer.Indexer;
1516
using Microsoft.CmdPal.Ext.Indexer.Properties;
1617
using Microsoft.CommandPalette.Extensions;
1718
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -120,12 +121,19 @@ private void ProcessSearchQuery(string query, CancellationToken ct)
120121
ct.ThrowIfCancellationRequested();
121122

122123
// We only need to know whether there are 0, 1, or more than one result
123-
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true);
124+
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, out var notice, noIcons: true);
124125
var count = results.Count;
125126

126127
if (count == 0)
127128
{
128-
ClearResultForCurrentQuery(ct);
129+
if (notice is { } searchNotice)
130+
{
131+
UpdateSearchNoticeForCurrentQuery(query, searchNotice, ct);
132+
}
133+
else
134+
{
135+
ClearResultForCurrentQuery(ct);
136+
}
129137
}
130138
else if (count == 1)
131139
{
@@ -233,6 +241,29 @@ private bool UpdateResultForCurrentQuery(string title, string subtitle, IIconInf
233241
}
234242
}
235243

244+
private bool UpdateSearchNoticeForCurrentQuery(string query, SearchNoticeInfo notice, CancellationToken ct)
245+
{
246+
var indexerPage = new IndexerPage(query);
247+
var set = UpdateResultForCurrentQuery(
248+
notice.Title,
249+
notice.Subtitle,
250+
Icons.FileExplorerIcon,
251+
indexerPage,
252+
[
253+
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
254+
],
255+
null,
256+
skipIcon: false,
257+
ct);
258+
259+
if (!set)
260+
{
261+
indexerPage.Dispose();
262+
}
263+
264+
return set;
265+
}
266+
236267
private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct)
237268
{
238269
lock (_resultLock)

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,26 @@ public static IDBInitialize GetDataSource()
2626

2727
private static bool InitializeDataSource()
2828
{
29-
var riid = typeof(IDBInitialize).GUID;
30-
3129
try
3230
{
3331
_dataSource = ComHelper.CreateComInstance<IDBInitialize>(ref Unsafe.AsRef(in CLSID.CollatorDataSource), CLSCTX.InProcServer);
3432
}
35-
catch (Exception e)
33+
catch (Exception ex)
3634
{
37-
Logger.LogError($"Failed to create datasource. ex: {e.Message}");
35+
Logger.LogError("Failed to create datasource.", ex);
3836
return false;
3937
}
4038

41-
_dataSource.Initialize();
39+
try
40+
{
41+
_dataSource.Initialize();
42+
}
43+
catch (Exception ex)
44+
{
45+
Logger.LogError("Failed to initialize datasource.", ex);
46+
_dataSource = null;
47+
return false;
48+
}
4249

4350
return true;
4451
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
6+
7+
internal readonly record struct SearchCatalogStatus(uint PendingItemsCount, int? HResult)
8+
{
9+
public bool IsAvailable => HResult is null;
10+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
#nullable enable
6+
7+
using System;
8+
using System.Runtime.CompilerServices;
9+
using System.Threading;
10+
using ManagedCommon;
11+
using ManagedCsWin32;
12+
using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch;
13+
14+
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
15+
16+
internal static class SearchCatalogStatusReader
17+
{
18+
private const string SystemIndex = "SystemIndex";
19+
private static readonly Lock FailureLoggingLock = new();
20+
private static int? _lastLoggedFailureHResult;
21+
22+
internal static SearchCatalogStatus GetStatus()
23+
{
24+
try
25+
{
26+
var catalogManager = CreateCatalogManager();
27+
var pendingItemsCount = catalogManager.NumberOfItemsToIndex();
28+
ResetFailureLoggingState();
29+
return new SearchCatalogStatus(pendingItemsCount, null);
30+
}
31+
catch (Exception ex)
32+
{
33+
LogFailure(ex);
34+
return new SearchCatalogStatus(0, ex.HResult);
35+
}
36+
}
37+
38+
private static ISearchCatalogManager CreateCatalogManager()
39+
{
40+
var searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
41+
var catalogManager = searchManager.GetCatalog(SystemIndex);
42+
return catalogManager ?? throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
43+
}
44+
45+
private static void LogFailure(Exception ex)
46+
{
47+
var shouldLogWarning = false;
48+
49+
lock (FailureLoggingLock)
50+
{
51+
if (_lastLoggedFailureHResult != ex.HResult)
52+
{
53+
_lastLoggedFailureHResult = ex.HResult;
54+
shouldLogWarning = true;
55+
}
56+
}
57+
58+
var message = $"Failed to read Windows Search catalog status. HResult=0x{ex.HResult:X8}, Message={ex.Message}";
59+
if (shouldLogWarning)
60+
{
61+
Logger.LogWarning(message);
62+
}
63+
else
64+
{
65+
Logger.LogDebug(message);
66+
}
67+
}
68+
69+
private static void ResetFailureLoggingState()
70+
{
71+
lock (FailureLoggingLock)
72+
{
73+
_lastLoggedFailureHResult = null;
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)