Compare commits

...

1 Commits

Author SHA1 Message Date
Jiří Polášek
d80d09509d Rebase onto main 2026-03-09 20:49:07 +01:00
46 changed files with 3821 additions and 381 deletions

View File

@@ -45,7 +45,6 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly SettingsModel _settings;
private readonly AppStateModel _appStateModel;
private readonly ScoringFunction<IListItem> _scoringFunction;
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// Stable separator instances so that the VM cache and InPlaceUpdateList
@@ -53,17 +52,22 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
private readonly Dictionary<string, Separator> _fallbackSourceSeparators = new(StringComparer.Ordinal);
private readonly Dictionary<string, CachedFallbackRenderBlock> _fallbackRenderBlocks = new(StringComparer.Ordinal);
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems;
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
private IReadOnlyList<TopLevelViewModel> _globalFallbackSources = [];
private IReadOnlyList<TopLevelViewModel> _rankedFallbackSources = [];
private IReadOnlyList<FallbackDescriptor> _fallbackDescriptors = [];
private FuzzyQuery _currentSearchQuery;
private string _currentSearchQueryText = string.Empty;
private string _fallbackRenderQueryText = string.Empty;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private bool _fallbackDescriptorsDirty = true;
private uint _fallbackRenderMatcherSchemaId;
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
@@ -95,7 +99,6 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager = topLevelCommandManager;
_fuzzyMatcherProvider = fuzzyMatcherProvider;
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current);
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks);
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
@@ -168,6 +171,7 @@ public sealed partial class MainListPage : DynamicListPage,
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
InvalidateFallbackDescriptorCache();
if (_includeApps != _filteredItemsIncludesApps)
{
ReapplySearchInBackground();
@@ -278,19 +282,15 @@ public sealed partial class MainListPage : DynamicListPage,
}
else
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var fallbackRenderPlan = BuildFallbackRenderPlan(GetCurrentSearchQuery());
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
fallbackRenderPlan.ScoredGlobalItems,
_filteredApps,
validFallbacks,
fallbackRenderPlan.LeadingItems,
fallbackRenderPlan.TrailingGlobalItems,
fallbackRenderPlan.OrderedFallbackItems,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
@@ -302,8 +302,60 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
_scoredFallbackItems = null;
_currentSearchQuery = default;
_currentSearchQueryText = string.Empty;
InvalidateFallbackRenderBlocks();
}
private void InvalidateFallbackDescriptorCache()
{
_fallbackDescriptors = [];
_fallbackDescriptorsDirty = true;
InvalidateFallbackRenderBlocks();
}
private void InvalidateFallbackRenderBlocks()
{
_fallbackRenderBlocks.Clear();
_fallbackRenderQueryText = string.Empty;
_fallbackRenderMatcherSchemaId = 0;
}
private void SetFallbackSources(IReadOnlyList<TopLevelViewModel> globalFallbackSources, IReadOnlyList<TopLevelViewModel> rankedFallbackSources)
{
var fallbackTopologyChanged = !SameFallbackSources(_globalFallbackSources, globalFallbackSources) ||
!SameFallbackSources(_rankedFallbackSources, rankedFallbackSources);
_globalFallbackSources = globalFallbackSources;
_rankedFallbackSources = rankedFallbackSources;
if (fallbackTopologyChanged)
{
InvalidateFallbackDescriptorCache();
}
}
private static bool SameFallbackSources(IReadOnlyList<TopLevelViewModel> left, IReadOnlyList<TopLevelViewModel> right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left.Count != right.Count)
{
return false;
}
for (var i = 0; i < left.Count; i++)
{
if (!ReferenceEquals(left[i], right[i]))
{
return false;
}
}
return true;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
@@ -337,9 +389,10 @@ public sealed partial class MainListPage : DynamicListPage,
if (aliases.CheckAlias(newSearch))
{
if (_filteredItemsIncludesApps != _includeApps)
lock (_tlcManager.TopLevelCommands)
{
lock (_tlcManager.TopLevelCommands)
CancelFallbackQueries(_tlcManager.TopLevelCommands);
if (_filteredItemsIncludesApps != _includeApps)
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
@@ -385,7 +438,13 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
_fallbackUpdateManager.BeginUpdate(SearchText, [.. specialFallbacks, .. commonFallbacks], token);
SetFallbackSources(specialFallbacks, commonFallbacks);
var allFallbacks = new List<TopLevelViewModel>(specialFallbacks.Count + commonFallbacks.Count);
allFallbacks.AddRange(specialFallbacks);
allFallbacks.AddRange(commonFallbacks);
UpdateInlineFallbacks(newSearch, allFallbacks);
_fallbackUpdateManager.BeginUpdate(newSearch, GetRemoteFallbacks(allFallbacks), token);
if (token.IsCancellationRequested)
{
@@ -395,6 +454,8 @@ public sealed partial class MainListPage : DynamicListPage,
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
if (string.IsNullOrWhiteSpace(newSearch))
{
CancelFallbackQueries(specialFallbacks);
CancelFallbackQueries(commonFallbacks);
_filteredItemsIncludesApps = _includeApps;
ClearResults();
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
@@ -422,7 +483,6 @@ public sealed partial class MainListPage : DynamicListPage,
}
var newFilteredItems = Enumerable.Empty<IListItem>();
var newFallbacks = Enumerable.Empty<IListItem>();
var newApps = Enumerable.Empty<IListItem>();
if (_filteredItems is not null)
@@ -445,27 +505,12 @@ public sealed partial class MainListPage : DynamicListPage,
return;
}
if (_fallbackItems is not null)
{
newFallbacks = _fallbackItems.Select(s => s.Item);
}
if (token.IsCancellationRequested)
{
return;
}
// If we don't have any previous filter results to work with, start
// with a list of all our commands & apps.
if (!newFilteredItems.Any() && !newApps.Any())
{
newFilteredItems = commands.Where(s => !s.IsFallback);
// Fallbacks are always included in the list, even if they
// don't match the search text. But we don't want to
// consider them when filtering the list.
newFallbacks = commonFallbacks;
if (token.IsCancellationRequested)
{
return;
@@ -499,6 +544,8 @@ public sealed partial class MainListPage : DynamicListPage,
}
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
_currentSearchQuery = searchQuery;
_currentSearchQueryText = SearchText;
// Produce a list of everything that matches the current filter.
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
@@ -508,21 +555,6 @@ public sealed partial class MainListPage : DynamicListPage,
return;
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
_fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
return;
}
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
@@ -602,6 +634,30 @@ public sealed partial class MainListPage : DynamicListPage,
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
}
else if (topLevelOrAppItem is FallbackQueryResultItem cachedFallbackResult)
{
isFallback = true;
extensionDisplayNameTarget = cachedFallbackResult.GetExtensionNameTarget(precomputedFuzzyMatcher);
if (cachedFallbackResult.HasAlias)
{
var alias = cachedFallbackResult.AliasText;
isAliasMatch = alias == query.Original;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
}
else if (topLevelOrAppItem is IFallbackResultItem fallbackResult)
{
isFallback = true;
extensionDisplayNameTarget = precomputedFuzzyMatcher.PrecomputeTarget(fallbackResult.ExtensionName);
if (fallbackResult.HasAlias)
{
var alias = fallbackResult.AliasText;
isAliasMatch = alias == query.Original;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
}
// Handle whitespace query separately - FuzzySearch doesn't handle it well
if (string.IsNullOrWhiteSpace(query.Original))
@@ -664,12 +720,11 @@ public sealed partial class MainListPage : DynamicListPage,
if (topLevelOrAppItem is TopLevelViewModel topLevelViewModel)
{
var index = Array.IndexOf(fallbackRanks, topLevelViewModel.Id);
if (index >= 0)
{
finalScore = fallbackRanks.Length - index + 1;
}
return ScoreFallbackSourceId(topLevelViewModel.Id, fallbackRanks);
}
else if (topLevelOrAppItem is IFallbackResultItem fallbackResultItem)
{
return ScoreFallbackSourceId(fallbackResultItem.FallbackSourceId, fallbackRanks);
}
return finalScore;
@@ -696,14 +751,316 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
private FuzzyQuery GetCurrentSearchQuery()
{
if (!string.Equals(_currentSearchQueryText, SearchText, StringComparison.Ordinal))
{
_currentSearchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
_currentSearchQueryText = SearchText;
}
return _currentSearchQuery;
}
private FallbackRenderPlan BuildFallbackRenderPlan(in FuzzyQuery searchQuery)
{
if (string.IsNullOrWhiteSpace(SearchText))
{
return FallbackRenderPlan.Empty;
}
EnsureFallbackRenderBlocksForQuery(in searchQuery);
List<RoScored<IListItem>> scoredGlobalItems = [];
List<IListItem> leadingItems = [];
List<IListItem> trailingGlobalItems = [];
List<RoScored<IListItem>> orderedFallbackItems = [];
HashSet<string> activeSourceIds = new(StringComparer.Ordinal);
foreach (var descriptor in GetFallbackDescriptors())
{
activeSourceIds.Add(descriptor.SourceId);
var block = GetOrCreateFallbackRenderBlock(descriptor, in searchQuery);
if (block.LeadingItems.Length > 0)
{
leadingItems.AddRange(block.LeadingItems);
}
if (block.ScoredGlobalItems.Length > 0)
{
scoredGlobalItems.AddRange(block.ScoredGlobalItems);
}
if (block.TrailingGlobalItems.Length > 0)
{
trailingGlobalItems.AddRange(block.TrailingGlobalItems);
}
if (block.OrderedFallbackItems.Length > 0)
{
orderedFallbackItems.AddRange(block.OrderedFallbackItems);
}
}
TrimUnusedFallbackRenderBlocks(activeSourceIds);
return new FallbackRenderPlan(scoredGlobalItems, leadingItems, trailingGlobalItems, orderedFallbackItems);
}
private IReadOnlyList<FallbackDescriptor> GetFallbackDescriptors()
{
if (!_fallbackDescriptorsDirty)
{
return _fallbackDescriptors;
}
List<FallbackDescriptor> descriptors = new(_globalFallbackSources.Count + _rankedFallbackSources.Count);
for (var i = 0; i < _globalFallbackSources.Count; i++)
{
descriptors.Add(CreateFallbackDescriptor(_globalFallbackSources[i], treatAsGlobal: true, score: 0, index: i));
}
foreach (var entry in GetOrderedRankedFallbackSources())
{
descriptors.Add(CreateFallbackDescriptor(entry.Source, treatAsGlobal: false, entry.Score, entry.Index));
}
_fallbackDescriptors = [.. descriptors];
_fallbackDescriptorsDirty = false;
return _fallbackDescriptors;
}
private FallbackDescriptor CreateFallbackDescriptor(TopLevelViewModel source, bool treatAsGlobal, int score, int index)
{
return new FallbackDescriptor(
Source: source,
SourceId: source.Id,
TreatAsGlobal: treatAsGlobal,
Score: score,
Index: index,
DisplayOptions: GetFallbackDisplayOptions(source),
ExecutionPolicy: source.GetFallbackExecutionPolicy(),
UsesInlineEvaluation: source.UsesInlineFallbackEvaluation,
UsesAsyncEvaluation: source.UsesAsyncFallbackEvaluation,
HostMatchKind: source.FallbackHostMatchKind);
}
private IEnumerable<OrderedFallbackSource> GetOrderedRankedFallbackSources()
{
return _rankedFallbackSources
.Select((source, index) => new OrderedFallbackSource(source, ScoreFallbackSourceId(source.Id, _settings.FallbackRanks), index))
.OrderByDescending(entry => entry.Score)
.ThenBy(entry => entry.Index);
}
private static int ScoreFallbackSourceId(string fallbackSourceId, string[] fallbackRanks)
{
var index = Array.IndexOf(fallbackRanks, fallbackSourceId);
return index >= 0 ? fallbackRanks.Length - index + 1 : 1;
}
private FallbackDisplayOptions GetFallbackDisplayOptions(TopLevelViewModel fallbackSource)
{
return TryGetFallbackSettings(fallbackSource, out var fallbackSettings)
? new FallbackDisplayOptions(
fallbackSettings.ShowResultsInDedicatedSection,
fallbackSettings.ShowResultsInDedicatedSection && fallbackSettings.ShowResultsBeforeMainResults)
: FallbackDisplayOptions.Default;
}
private bool TryGetFallbackSettings(TopLevelViewModel fallbackSource, out FallbackSettings fallbackSettings)
{
fallbackSettings = default!;
if (!_settings.ProviderSettings.TryGetValue(fallbackSource.CommandProviderId, out var providerSettings))
{
return false;
}
if (providerSettings.FallbackCommands.TryGetValue(fallbackSource.Id, out var settings) && settings is not null)
{
fallbackSettings = settings;
return true;
}
return false;
}
private void EnsureFallbackRenderBlocksForQuery(in FuzzyQuery searchQuery)
{
var matcherSchemaId = _fuzzyMatcherProvider.Current.SchemaId;
if (string.Equals(_fallbackRenderQueryText, searchQuery.Original, StringComparison.Ordinal) &&
_fallbackRenderMatcherSchemaId == matcherSchemaId)
{
return;
}
_fallbackRenderQueryText = searchQuery.Original;
_fallbackRenderMatcherSchemaId = matcherSchemaId;
_fallbackRenderBlocks.Clear();
}
private FallbackRenderBlock GetOrCreateFallbackRenderBlock(FallbackDescriptor descriptor, in FuzzyQuery searchQuery)
{
var currentItems = descriptor.Source.GetCurrentFallbackItems();
var sectionSeparator = (descriptor.DisplayOptions.ShowResultsInDedicatedSection || descriptor.DisplayOptions.ShowBeforeMainResults)
? GetFallbackSectionSeparator(descriptor.Source)
: null;
if (_fallbackRenderBlocks.TryGetValue(descriptor.SourceId, out var cachedBlock) &&
cachedBlock.CanReuse(currentItems, descriptor))
{
return cachedBlock.Block;
}
var block = FallbackRenderBlockFactory.Create(
currentItems,
descriptor.TreatAsGlobal,
descriptor.Score,
descriptor.DisplayOptions.ShowResultsInDedicatedSection,
descriptor.DisplayOptions.ShowBeforeMainResults,
sectionSeparator,
searchQuery,
_scoringFunction);
_fallbackRenderBlocks[descriptor.SourceId] = new CachedFallbackRenderBlock(currentItems, descriptor, block);
return block;
}
private void TrimUnusedFallbackRenderBlocks(HashSet<string> activeSourceIds)
{
if (_fallbackRenderBlocks.Count <= activeSourceIds.Count)
{
return;
}
var staleSourceIds = _fallbackRenderBlocks.Keys
.Where(sourceId => !activeSourceIds.Contains(sourceId))
.ToArray();
for (var i = 0; i < staleSourceIds.Length; i++)
{
_fallbackRenderBlocks.Remove(staleSourceIds[i]);
}
}
private Separator GetFallbackSectionSeparator(TopLevelViewModel fallbackSource)
{
var title = !string.IsNullOrWhiteSpace(fallbackSource.DisplayTitle)
? fallbackSource.DisplayTitle
: !string.IsNullOrWhiteSpace(fallbackSource.Title)
? fallbackSource.Title
: fallbackSource.ExtensionName;
if (!_fallbackSourceSeparators.TryGetValue(fallbackSource.Id, out var separator))
{
separator = new Separator(title);
_fallbackSourceSeparators[fallbackSource.Id] = separator;
}
else if (!string.Equals(separator.Title, title, StringComparison.Ordinal))
{
separator.Title = title;
}
return separator;
}
private readonly record struct FallbackRenderPlan(
List<RoScored<IListItem>> ScoredGlobalItems,
List<IListItem> LeadingItems,
List<IListItem> TrailingGlobalItems,
List<RoScored<IListItem>> OrderedFallbackItems)
{
public static readonly FallbackRenderPlan Empty = new([], [], [], []);
}
private readonly record struct FallbackDescriptor(
TopLevelViewModel Source,
string SourceId,
bool TreatAsGlobal,
int Score,
int Index,
FallbackDisplayOptions DisplayOptions,
FallbackExecutionPolicy ExecutionPolicy,
bool UsesInlineEvaluation,
bool UsesAsyncEvaluation,
HostMatchKind? HostMatchKind);
private readonly record struct CachedFallbackRenderBlock(
IListItem[] SourceItems,
FallbackDescriptor Descriptor,
FallbackRenderBlock Block)
{
internal bool CanReuse(IListItem[] currentItems, FallbackDescriptor descriptor)
{
return ReferenceEquals(SourceItems, currentItems) &&
Descriptor.TreatAsGlobal == descriptor.TreatAsGlobal &&
Descriptor.Score == descriptor.Score &&
Descriptor.DisplayOptions == descriptor.DisplayOptions;
}
}
private readonly record struct OrderedFallbackSource(TopLevelViewModel Source, int Score, int Index);
private readonly record struct FallbackDisplayOptions(bool ShowResultsInDedicatedSection, bool ShowBeforeMainResults)
{
public static readonly FallbackDisplayOptions Default = new(false, false);
}
private static void CancelFallbackQueries(IEnumerable<TopLevelViewModel> commands)
{
foreach (var command in commands)
{
if (command.IsFallback)
{
command.CancelOutstandingFallbackQuery();
}
}
}
private static IReadOnlyList<TopLevelViewModel> GetRemoteFallbacks(IEnumerable<TopLevelViewModel> commands)
{
List<TopLevelViewModel> remoteFallbacks = [];
foreach (var command in commands)
{
if (!command.UsesInlineFallbackEvaluation)
{
remoteFallbacks.Add(command);
}
}
return remoteFallbacks;
}
private static void UpdateInlineFallbacks(string query, IEnumerable<TopLevelViewModel> commands)
{
foreach (var command in commands)
{
if (command.UsesInlineFallbackEvaluation)
{
command.SafeUpdateFallbackTextInline(query);
}
}
}
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message)
{
RequestRefresh(fullRefresh: false);
RequestRefresh(fullRefresh: false, interval: RaiseItemsChangedThrottleForUserInput);
}
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
private void SettingsChangedHandler(SettingsModel sender, object? args)
{
InvalidateFallbackDescriptorCache();
HotReloadSettings(sender);
if (!string.IsNullOrWhiteSpace(SearchText))
{
RequestRefresh(fullRefresh: true, interval: TimeSpan.Zero);
}
}
private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
@@ -712,6 +1069,10 @@ public sealed partial class MainListPage : DynamicListPage,
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_fallbackUpdateManager.Dispose();
lock (_tlcManager.TopLevelCommands)
{
CancelFallbackQueries(_tlcManager.TopLevelCommands);
}
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;

View File

@@ -24,6 +24,59 @@ internal static class MainListPageResultFactory
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit)
{
return Create(
filteredItems,
scoredFallbackItems,
filteredApps,
null,
null,
fallbackItems,
resultsSeparator,
fallbacksSeparator,
appResultLimit);
}
/// <summary>
/// Creates a merged and ordered array of results from multiple scored input lists,
/// while also allowing dedicated fallback section blocks to be appended after merged results.
/// </summary>
public static IListItem[] Create(
IList<RoScored<IListItem>>? filteredItems,
IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps,
IList<IListItem>? sectionedGlobalFallbackItems,
IList<RoScored<IListItem>>? fallbackItems,
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit)
{
return Create(
filteredItems,
scoredFallbackItems,
filteredApps,
null,
sectionedGlobalFallbackItems,
fallbackItems,
resultsSeparator,
fallbacksSeparator,
appResultLimit);
}
/// <summary>
/// Creates a merged and ordered array of results from multiple scored input lists,
/// while also allowing dedicated fallback section blocks to appear before or after merged results.
/// </summary>
public static IListItem[] Create(
IList<RoScored<IListItem>>? filteredItems,
IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps,
IList<IListItem>? leadingFallbackItems,
IList<IListItem>? sectionedGlobalFallbackItems,
IList<RoScored<IListItem>>? fallbackItems,
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit)
{
if (appResultLimit < 0)
{
@@ -39,6 +92,9 @@ internal static class MainListPageResultFactory
// Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit.
int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit);
int leadingCount = leadingFallbackItems?.Count ?? 0;
int len4 = sectionedGlobalFallbackItems?.Count ?? 0;
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
// Allocate the exact size of the result array.
@@ -46,7 +102,7 @@ internal static class MainListPageResultFactory
// and another for the "Results" section header when merged results exist.
int mergedCount = len1 + len2 + len3;
bool needsResultsHeader = mergedCount > 0;
int totalCount = mergedCount + nonEmptyFallbackCount
int totalCount = leadingCount + mergedCount + len4 + nonEmptyFallbackCount
+ (needsResultsHeader ? 1 : 0)
+ (nonEmptyFallbackCount > 0 ? 1 : 0);
@@ -56,6 +112,14 @@ internal static class MainListPageResultFactory
int idx1 = 0, idx2 = 0, idx3 = 0;
int writePos = 0;
if (leadingFallbackItems is not null)
{
for (int i = 0; i < leadingFallbackItems.Count; i++)
{
result[writePos++] = leadingFallbackItems[i];
}
}
// Add "Results" section header when merged results will precede the fallbacks.
if (needsResultsHeader)
{
@@ -138,6 +202,14 @@ internal static class MainListPageResultFactory
result[writePos++] = filteredApps![idx3++].Item;
}
if (sectionedGlobalFallbackItems is not null)
{
for (int i = 0; i < sectionedGlobalFallbackItems.Count; i++)
{
result[writePos++] = sectionedGlobalFallbackItems[i];
}
}
// Append filtered fallback items. Fallback items are added post-sort so they are
// always at the end of the list and are sorted by user settings.
if (fallbackItems is not null)

View File

@@ -0,0 +1,574 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Concurrent;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text.RegularExpressions;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed class FallbackExecutionState
{
private static readonly ConcurrentDictionary<string, Regex?> RegexCache = new(StringComparer.Ordinal);
private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(50);
private readonly Lock _stateLock = new();
private readonly IFallbackCommandItem _fallbackCommandItem;
private readonly IFallbackHandler2? _asyncFallbackHandler;
private readonly IFormattedFallbackCommandItem? _formattedFallbackCommandItem;
private readonly IHostMatchedFallbackCommandItem? _hostMatchedFallbackCommandItem;
private readonly Func<FallbackExecutionPolicy> _getPolicy;
private readonly MaterializeFallbackSnapshotItemsCallback _materializeSnapshotItems;
private readonly Action _requestRefresh;
private FallbackQueryState _queryState = FallbackQueryState.Empty;
private long _querySequence;
private PendingAsyncFallbackQuery? _pendingAsyncFallbackQuery;
private string _activeAsyncFallbackQueryId = string.Empty;
private CancellationTokenSource? _scheduledAsyncFallbackCts;
internal FallbackExecutionState(
IFallbackCommandItem fallbackCommandItem,
IFallbackHandler2? asyncFallbackHandler,
IFormattedFallbackCommandItem? formattedFallbackCommandItem,
IHostMatchedFallbackCommandItem? hostMatchedFallbackCommandItem,
Func<FallbackExecutionPolicy> getPolicy,
MaterializeFallbackSnapshotItemsCallback materializeSnapshotItems,
Action requestRefresh)
{
_fallbackCommandItem = fallbackCommandItem;
_asyncFallbackHandler = asyncFallbackHandler;
_formattedFallbackCommandItem = formattedFallbackCommandItem;
_hostMatchedFallbackCommandItem = hostMatchedFallbackCommandItem;
_getPolicy = getPolicy;
_materializeSnapshotItems = materializeSnapshotItems;
_requestRefresh = requestRefresh;
}
internal bool UsesInlineEvaluation => _formattedFallbackCommandItem is not null;
internal FallbackExecutionPolicy GetExecutionPolicy() => _getPolicy();
internal bool UpdateSynchronous(string query, IListItem sourceItem)
{
return UpdateCore(query, sourceItem, inlineOnly: false);
}
internal bool UpdateInline(string query, IListItem sourceItem)
{
if (_formattedFallbackCommandItem is null)
{
return false;
}
return UpdateCore(query, sourceItem, inlineOnly: true);
}
internal IListItem[] GetCurrentItems()
{
lock (_stateLock)
{
return _queryState.QueryId == _queryState.LatestRequestedQueryId
? _queryState.Items
: [];
}
}
internal void CancelOutstandingQuery()
{
string currentQueryId;
string activeAsyncQueryId;
CancellationTokenSource? scheduledAsyncFallbackCts;
lock (_stateLock)
{
currentQueryId = _queryState.LatestRequestedQueryId;
activeAsyncQueryId = _activeAsyncFallbackQueryId;
_activeAsyncFallbackQueryId = string.Empty;
_pendingAsyncFallbackQuery = null;
scheduledAsyncFallbackCts = _scheduledAsyncFallbackCts;
_scheduledAsyncFallbackCts = null;
_queryState = FallbackQueryState.Empty;
}
CancelAndDispose(scheduledAsyncFallbackCts);
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, activeAsyncQueryId);
if (!string.Equals(activeAsyncQueryId, currentQueryId, StringComparison.Ordinal))
{
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, currentQueryId);
}
}
internal bool IsCurrentQueryId(string queryId)
{
lock (_stateLock)
{
return !string.IsNullOrEmpty(queryId) && _queryState.LatestRequestedQueryId == queryId;
}
}
internal IFallbackCommandInvocationArgs? GetCurrentInvocationArgs()
{
lock (_stateLock)
{
if (string.IsNullOrEmpty(_queryState.LatestRequestedQueryId))
{
return null;
}
return new FallbackCommandInvocationArgs()
{
Query = _queryState.LatestRequestedQuery,
QueryId = _queryState.LatestRequestedQueryId,
};
}
}
internal static bool IsRegexMatch(string pattern, string query)
{
if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(query))
{
return false;
}
var regex = RegexCache.GetOrAdd(pattern, CreateFallbackRegex);
return regex?.IsMatch(query) ?? false;
}
private bool UpdateCore(string query, IListItem sourceItem, bool inlineOnly)
{
if (!GetExecutionPolicy().ShouldEvaluate(query))
{
return SuppressQuery(query);
}
if (_formattedFallbackCommandItem is not null)
{
return ApplyFormattedSnapshot(query, sourceItem);
}
if (inlineOnly)
{
return false;
}
var (queryId, previousQueryId, hadVisibleResults) = BeginQuery(query);
if (_asyncFallbackHandler is not null)
{
QueueAsyncQuery(query, queryId);
return hadVisibleResults;
}
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, previousQueryId);
_fallbackCommandItem.FallbackHandler.UpdateQuery(query);
var snapshotItems = string.IsNullOrWhiteSpace(sourceItem.Title)
? Array.Empty<IListItem>()
: [sourceItem];
var snapshotDefinitions = snapshotItems
.Select(item => new FallbackSnapshotDefinition(item, true, null, null))
.ToArray();
return TryApplySnapshot(query, queryId, snapshotDefinitions, hadVisibleResults);
}
private bool SuppressQuery(string query)
{
var (queryId, _, hadVisibleResults) = BeginQuery(query);
CancelPendingAndScheduledQueries(queryId);
return TryApplySnapshot(query, queryId, [], hadVisibleResults);
}
private (string QueryId, string PreviousQueryId, bool HadVisibleResults) BeginQuery(string query)
{
lock (_stateLock)
{
var previousQueryId = _queryState.LatestRequestedQueryId;
var hadVisibleResults = _queryState.Items.Length > 0 && _queryState.QueryId == previousQueryId;
var queryId = Interlocked.Increment(ref _querySequence).ToString(System.Globalization.CultureInfo.InvariantCulture);
_queryState = new FallbackQueryState(
LatestRequestedQuery: query,
LatestRequestedQueryId: queryId,
Query: query,
QueryId: queryId,
Items: []);
return (queryId, previousQueryId, hadVisibleResults);
}
}
private bool ApplyFormattedSnapshot(string query, IListItem sourceItem)
{
if (_formattedFallbackCommandItem is null)
{
return false;
}
var (queryId, _, hadVisibleResults) = BeginQuery(query);
if (_hostMatchedFallbackCommandItem is not null && !IsHostMatch(_hostMatchedFallbackCommandItem, query))
{
return TryApplySnapshot(query, queryId, [], hadVisibleResults);
}
var formattedItems = new[]
{
new FallbackSnapshotDefinition(
sourceItem,
false,
FormatTemplate(_formattedFallbackCommandItem.TitleTemplate, query),
FormatTemplate(_formattedFallbackCommandItem.SubtitleTemplate, query)),
};
return TryApplySnapshot(query, queryId, formattedItems, hadVisibleResults);
}
private void QueueAsyncQuery(string query, string queryId)
{
CancellationTokenSource? scheduledAsyncFallbackCts;
string activeAsyncQueryId;
bool shouldSchedulePendingQuery;
lock (_stateLock)
{
_pendingAsyncFallbackQuery = new PendingAsyncFallbackQuery(query, queryId);
scheduledAsyncFallbackCts = _scheduledAsyncFallbackCts;
_scheduledAsyncFallbackCts = null;
activeAsyncQueryId = _activeAsyncFallbackQueryId;
shouldSchedulePendingQuery = string.IsNullOrEmpty(activeAsyncQueryId);
}
CancelAndDispose(scheduledAsyncFallbackCts);
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, activeAsyncQueryId);
if (shouldSchedulePendingQuery)
{
SchedulePendingAsyncQuery();
}
}
private void SchedulePendingAsyncQuery()
{
PendingAsyncFallbackQuery? pendingQuery;
CancellationTokenSource? delayCts = null;
var delay = GetExecutionPolicy().Delay;
lock (_stateLock)
{
if (_pendingAsyncFallbackQuery is null || !string.IsNullOrEmpty(_activeAsyncFallbackQueryId))
{
return;
}
pendingQuery = _pendingAsyncFallbackQuery;
if (delay > TimeSpan.Zero)
{
delayCts = new CancellationTokenSource();
_scheduledAsyncFallbackCts = delayCts;
}
else
{
_activeAsyncFallbackQueryId = pendingQuery.Value.QueryId;
_pendingAsyncFallbackQuery = null;
}
}
if (pendingQuery is null)
{
return;
}
if (delay <= TimeSpan.Zero)
{
StartAsyncQuery(pendingQuery.Value.Query, pendingQuery.Value.QueryId);
return;
}
_ = WaitAndStartPendingAsyncQueryAsync(pendingQuery.Value.QueryId, delay, delayCts!);
}
private async Task WaitAndStartPendingAsyncQueryAsync(string queryId, TimeSpan delay, CancellationTokenSource delayCts)
{
try
{
await Task.Delay(delay, delayCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
delayCts.Dispose();
return;
}
string? queryToStart = null;
try
{
lock (_stateLock)
{
if (!ReferenceEquals(_scheduledAsyncFallbackCts, delayCts)
|| _pendingAsyncFallbackQuery is not { } pendingQuery
|| !string.Equals(pendingQuery.QueryId, queryId, StringComparison.Ordinal)
|| !string.IsNullOrEmpty(_activeAsyncFallbackQueryId))
{
return;
}
_scheduledAsyncFallbackCts = null;
_pendingAsyncFallbackQuery = null;
_activeAsyncFallbackQueryId = pendingQuery.QueryId;
queryToStart = pendingQuery.Query;
}
}
finally
{
delayCts.Dispose();
}
StartAsyncQuery(queryToStart, queryId);
}
private void StartAsyncQuery(string query, string queryId)
{
if (_asyncFallbackHandler is null)
{
CompleteAsyncQuery(queryId);
return;
}
try
{
var operation = _asyncFallbackHandler.UpdateQueryAsync(query, queryId);
_ = AwaitFallbackResultsAsync(operation, query, queryId);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
CompleteAsyncQuery(queryId);
}
}
private async Task AwaitFallbackResultsAsync(IAsyncOperation<IFallbackCommandResult> operation, string query, string queryId)
{
try
{
var result = await operation.AsTask().ConfigureAwait(false);
var resultQuery = result?.Query ?? query;
var resultQueryId = result?.QueryId ?? queryId;
var items = CreateSnapshotItems(result?.Items);
if (!TryApplySnapshot(resultQuery, resultQueryId, items, hadVisibleResults: false))
{
return;
}
if (items.Length > 0)
{
_requestRefresh();
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
finally
{
CompleteAsyncQuery(queryId);
}
}
private void CompleteAsyncQuery(string queryId)
{
bool shouldSchedulePendingQuery;
lock (_stateLock)
{
if (string.Equals(_activeAsyncFallbackQueryId, queryId, StringComparison.Ordinal))
{
_activeAsyncFallbackQueryId = string.Empty;
}
shouldSchedulePendingQuery = string.IsNullOrEmpty(_activeAsyncFallbackQueryId) && _pendingAsyncFallbackQuery is not null;
}
if (shouldSchedulePendingQuery)
{
SchedulePendingAsyncQuery();
}
}
private FallbackSnapshotDefinition[] CreateSnapshotItems(IEnumerable<IListItem>? items)
{
if (items is null)
{
return [];
}
return items
.Where(item => item is not null && !string.IsNullOrWhiteSpace(item.Title))
.Select(item => new FallbackSnapshotDefinition(item, true, null, null))
.ToArray();
}
private bool TryApplySnapshot(string query, string queryId, IReadOnlyList<FallbackSnapshotDefinition> items, bool hadVisibleResults)
{
lock (_stateLock)
{
if (_queryState.LatestRequestedQueryId != queryId)
{
return false;
}
var materializedItems = _materializeSnapshotItems(query, queryId, items);
_queryState = _queryState with
{
Query = query,
QueryId = queryId,
Items = materializedItems,
};
return hadVisibleResults || materializedItems.Length > 0;
}
}
private void CancelPendingAndScheduledQueries(string queryId)
{
string activeAsyncQueryId;
CancellationTokenSource? scheduledAsyncFallbackCts;
lock (_stateLock)
{
activeAsyncQueryId = _activeAsyncFallbackQueryId;
_activeAsyncFallbackQueryId = string.Empty;
_pendingAsyncFallbackQuery = null;
scheduledAsyncFallbackCts = _scheduledAsyncFallbackCts;
_scheduledAsyncFallbackCts = null;
}
CancelAndDispose(scheduledAsyncFallbackCts);
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, activeAsyncQueryId);
if (!string.Equals(activeAsyncQueryId, queryId, StringComparison.Ordinal))
{
CancelQueryIfSupported(_fallbackCommandItem.FallbackHandler, queryId);
}
}
private static void CancelQueryIfSupported(IFallbackHandler? fallbackHandler, string queryId)
{
if (string.IsNullOrEmpty(queryId) || fallbackHandler is not IFallbackHandler2 asyncFallbackHandler)
{
return;
}
try
{
asyncFallbackHandler.CancelQuery(queryId);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
}
private static void CancelAndDispose(CancellationTokenSource? cancellationTokenSource)
{
if (cancellationTokenSource is null)
{
return;
}
try
{
cancellationTokenSource.Cancel();
}
catch (ObjectDisposedException)
{
}
finally
{
cancellationTokenSource.Dispose();
}
}
private static string FormatTemplate(string template, string query)
{
return string.IsNullOrEmpty(template)
? string.Empty
: template.Replace("{query}", query, StringComparison.Ordinal);
}
private static Regex? CreateFallbackRegex(string pattern)
{
try
{
return new Regex(
$"^(?:{pattern})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.NonBacktracking,
RegexTimeout);
}
catch (ArgumentException ex)
{
Logger.LogError(ex.ToString());
return null;
}
}
private static bool IsHostMatch(IHostMatchedFallbackCommandItem hostMatchedFallback, string query)
{
return hostMatchedFallback.MatchKind switch
{
HostMatchKind.Regex => IsRegexMatch(hostMatchedFallback.MatchValue, query),
_ => !string.IsNullOrWhiteSpace(query),
};
}
private readonly record struct FallbackQueryState(
string LatestRequestedQuery,
string LatestRequestedQueryId,
string Query,
string QueryId,
IListItem[] Items)
{
public static readonly FallbackQueryState Empty = new(string.Empty, string.Empty, string.Empty, string.Empty, []);
}
private readonly record struct PendingAsyncFallbackQuery(string Query, string QueryId);
}
internal readonly record struct FallbackExecutionPolicy(TimeSpan Delay, uint MinQueryLength)
{
internal static readonly FallbackExecutionPolicy Empty = new(TimeSpan.Zero, 0);
internal bool ShouldEvaluate(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return false;
}
return query.Trim().Length >= MinQueryLength;
}
internal TimeSpan GetDelayForQuery(string query) => ShouldEvaluate(query) ? Delay : TimeSpan.Zero;
}
internal readonly record struct FallbackSnapshotDefinition(
IListItem SourceItem,
bool ListenForSourceItemUpdates,
string? TitleOverride,
string? SubtitleOverride);
internal delegate IListItem[] MaterializeFallbackSnapshotItemsCallback(
string query,
string queryId,
IReadOnlyList<FallbackSnapshotDefinition> snapshotItems);

View File

@@ -0,0 +1,202 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed partial class FallbackQueryResultItem : ListItem, IFallbackResultItem, IPrecomputedListItem
{
private readonly Lock _sourceItemLock = new();
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
private FuzzyTargetCache _extensionNameCache;
private Func<bool> _isCurrent = static () => false;
private bool _usesTitleOverride;
private bool _usesSubtitleOverride;
private TypedEventHandler<object, IPropChangedEventArgs>? _sourceItemChangedHandler;
private IListItem? _sourceItem;
public FallbackQueryResultItem(
string identity,
IListItem sourceItem,
AppExtensionHost extensionHost,
ICommandProviderContext providerContext,
string fallbackSourceId,
string extensionName,
string aliasText,
bool hasAlias,
IFallbackCommandInvocationArgs? invocationArgs,
Func<bool> isCurrent,
bool listenForSourceItemUpdates = false,
string? titleOverride = null,
string? subtitleOverride = null)
: base()
{
Identity = identity;
ExtensionHost = extensionHost;
ProviderContext = providerContext;
FallbackSourceId = fallbackSourceId;
ExtensionName = extensionName;
RefreshFromSource(sourceItem, aliasText, hasAlias, invocationArgs, isCurrent, listenForSourceItemUpdates, titleOverride, subtitleOverride);
}
public string FallbackSourceId { get; }
public string ExtensionName { get; }
public bool HasAlias { get; private set; }
public string AliasText { get; private set; } = string.Empty;
public AppExtensionHost ExtensionHost { get; }
public ICommandProviderContext ProviderContext { get; }
public IFallbackCommandInvocationArgs? InvocationArgs { get; private set; }
public bool IsCurrent => _isCurrent();
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
internal FuzzyTarget GetExtensionNameTarget(IPrecomputedFuzzyMatcher matcher) => _extensionNameCache.GetOrUpdate(matcher, ExtensionName);
internal void RefreshFromSource(
IListItem sourceItem,
string aliasText,
bool hasAlias,
IFallbackCommandInvocationArgs? invocationArgs,
Func<bool> isCurrent,
bool listenForSourceItemUpdates,
string? titleOverride,
string? subtitleOverride)
{
DetachSourceItemUpdates();
AliasText = aliasText;
HasAlias = hasAlias;
InvocationArgs = invocationArgs;
_isCurrent = isCurrent;
_usesTitleOverride = titleOverride is not null;
_usesSubtitleOverride = subtitleOverride is not null;
Command = sourceItem.Command;
MoreCommands = sourceItem.MoreCommands;
Icon = sourceItem.Icon;
Title = titleOverride ?? sourceItem.Title;
Subtitle = subtitleOverride ?? sourceItem.Subtitle;
Tags = sourceItem.Tags;
Details = sourceItem.Details;
Section = sourceItem.Section;
TextToSuggest = string.IsNullOrEmpty(sourceItem.TextToSuggest) ? invocationArgs?.Query ?? string.Empty : sourceItem.TextToSuggest;
CopyDataPackage(sourceItem);
AttachSourceItemUpdates(sourceItem, listenForSourceItemUpdates);
}
internal void DetachSourceItemUpdates()
{
lock (_sourceItemLock)
{
if (_sourceItem is not null && _sourceItemChangedHandler is not null)
{
_sourceItem.PropChanged -= _sourceItemChangedHandler;
}
_sourceItem = null;
_sourceItemChangedHandler = null;
}
}
private void AttachSourceItemUpdates(IListItem sourceItem, bool listenForSourceItemUpdates)
{
if (!listenForSourceItemUpdates)
{
return;
}
TypedEventHandler<object, IPropChangedEventArgs>? handler = null;
var weakThis = new WeakReference<FallbackQueryResultItem>(this);
handler = (sender, args) =>
{
if (!weakThis.TryGetTarget(out var target))
{
sourceItem.PropChanged -= handler;
return;
}
target.HandleSourceItemChanged(sourceItem, args);
};
lock (_sourceItemLock)
{
_sourceItem = sourceItem;
_sourceItemChangedHandler = handler;
}
sourceItem.PropChanged += handler;
}
private void HandleSourceItemChanged(IListItem sourceItem, IPropChangedEventArgs args)
{
switch (args.PropertyName)
{
case nameof(ICommandItem.Icon):
Icon = sourceItem.Icon;
break;
case nameof(ICommandItem.Command):
Command = sourceItem.Command;
break;
case nameof(ICommandItem.MoreCommands):
MoreCommands = sourceItem.MoreCommands;
break;
case nameof(ICommandItem.Title) when !_usesTitleOverride:
Title = sourceItem.Title;
break;
case nameof(ICommandItem.Subtitle) when !_usesSubtitleOverride:
Subtitle = sourceItem.Subtitle;
break;
case nameof(IListItem.Tags):
Tags = sourceItem.Tags;
break;
case nameof(IListItem.Details):
Details = sourceItem.Details;
break;
case nameof(IListItem.Section):
Section = sourceItem.Section;
break;
case nameof(IListItem.TextToSuggest):
TextToSuggest = sourceItem.TextToSuggest;
break;
case nameof(CommandItem.DataPackage):
case nameof(CommandItem.DataPackageView):
CopyDataPackage(sourceItem);
break;
}
}
private void CopyDataPackage(ICommandItem sourceItem)
{
DataPackageView = null;
if (sourceItem is not IExtendedAttributesProvider extendedAttributesProvider)
{
return;
}
var properties = extendedAttributesProvider.GetProperties();
if (properties?.TryGetValue(WellKnownExtensionAttributes.DataPackage, out var dataPackage) == true &&
dataPackage is DataPackageView view)
{
DataPackageView = view;
}
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
namespace Microsoft.CmdPal.UI.ViewModels;
internal static class FallbackRefreshNotifier
{
private static readonly ThrottledDebouncedAction RefreshAction = new(
() => WeakReferenceMessenger.Default.Send<UpdateFallbackItemsMessage>(),
TimeSpan.FromMilliseconds(25));
public static void RequestRefresh()
{
RefreshAction.Invoke();
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -12,19 +12,35 @@ public class FallbackSettings
public bool IncludeInGlobalResults { get; set; }
public bool ShowResultsInDedicatedSection { get; set; }
public bool ShowResultsBeforeMainResults { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public uint? QueryDelayMilliseconds { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public uint? MinQueryLength { get; set; }
public FallbackSettings()
{
}
public FallbackSettings(bool isBuiltIn)
public FallbackSettings(bool includeInGlobalResults, uint? queryDelayMilliseconds = null, uint? minQueryLength = null)
{
IncludeInGlobalResults = isBuiltIn;
IncludeInGlobalResults = includeInGlobalResults;
QueryDelayMilliseconds = queryDelayMilliseconds;
MinQueryLength = minQueryLength;
}
[JsonConstructor]
public FallbackSettings(bool isEnabled, bool includeInGlobalResults)
public FallbackSettings(bool isEnabled, bool includeInGlobalResults, bool showResultsInDedicatedSection, bool showResultsBeforeMainResults, uint? queryDelayMilliseconds = null, uint? minQueryLength = null)
{
IsEnabled = isEnabled;
IncludeInGlobalResults = includeInGlobalResults;
ShowResultsInDedicatedSection = showResultsInDedicatedSection;
ShowResultsBeforeMainResults = showResultsBeforeMainResults;
QueryDelayMilliseconds = queryDelayMilliseconds;
MinQueryLength = minQueryLength;
}
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -12,6 +13,8 @@ public partial class FallbackSettingsViewModel : ObservableObject
{
private readonly SettingsModel _settings;
private readonly FallbackSettings _fallbackSettings;
private readonly uint? _suggestedQueryDelayMilliseconds;
private readonly uint? _suggestedMinQueryLength;
public string DisplayName { get; private set; } = string.Empty;
@@ -35,6 +38,9 @@ public partial class FallbackSettingsViewModel : ObservableObject
Save();
OnPropertyChanged(nameof(IsEnabled));
OnPropertyChanged(nameof(CanShowResultsBeforeMainResults));
OnPropertyChanged(nameof(CanEditQueryDelayMilliseconds));
OnPropertyChanged(nameof(CanEditMinQueryLength));
}
}
}
@@ -59,6 +65,125 @@ public partial class FallbackSettingsViewModel : ObservableObject
}
}
public bool ShowResultsInDedicatedSection
{
get => _fallbackSettings.ShowResultsInDedicatedSection;
set
{
if (value != _fallbackSettings.ShowResultsInDedicatedSection)
{
_fallbackSettings.ShowResultsInDedicatedSection = value;
Save();
OnPropertyChanged(nameof(ShowResultsInDedicatedSection));
OnPropertyChanged(nameof(CanShowResultsBeforeMainResults));
}
}
}
public bool ShowResultsBeforeMainResults
{
get => _fallbackSettings.ShowResultsBeforeMainResults;
set
{
if (value != _fallbackSettings.ShowResultsBeforeMainResults)
{
_fallbackSettings.ShowResultsBeforeMainResults = value;
Save();
OnPropertyChanged(nameof(ShowResultsBeforeMainResults));
}
}
}
public bool CanShowResultsBeforeMainResults => IsEnabled && ShowResultsInDedicatedSection;
public bool HasQueryDelayOverride
{
get => _fallbackSettings.QueryDelayMilliseconds.HasValue;
set
{
if (value == HasQueryDelayOverride)
{
return;
}
_fallbackSettings.QueryDelayMilliseconds = value ? _fallbackSettings.QueryDelayMilliseconds ?? _suggestedQueryDelayMilliseconds ?? 0 : null;
Save();
OnPropertyChanged(nameof(HasQueryDelayOverride));
OnPropertyChanged(nameof(QueryDelayMillisecondsValue));
OnPropertyChanged(nameof(CanEditQueryDelayMilliseconds));
}
}
public double QueryDelayMillisecondsValue
{
get => _fallbackSettings.QueryDelayMilliseconds ?? _suggestedQueryDelayMilliseconds ?? 0;
set
{
var normalizedValue = NormalizeUnsignedValue(value);
if (_fallbackSettings.QueryDelayMilliseconds == normalizedValue)
{
return;
}
_fallbackSettings.QueryDelayMilliseconds = normalizedValue;
Save();
OnPropertyChanged(nameof(QueryDelayMillisecondsValue));
OnPropertyChanged(nameof(HasQueryDelayOverride));
OnPropertyChanged(nameof(CanEditQueryDelayMilliseconds));
}
}
public bool CanEditQueryDelayMilliseconds => IsEnabled && HasQueryDelayOverride;
public string QueryDelayDescription => FormatSuggestedValueDescription(
"FallbackQueryDelayDescriptionWithSuggestion",
"FallbackQueryDelayDescriptionWithoutSuggestion",
_suggestedQueryDelayMilliseconds);
public bool HasMinQueryLengthOverride
{
get => _fallbackSettings.MinQueryLength.HasValue;
set
{
if (value == HasMinQueryLengthOverride)
{
return;
}
_fallbackSettings.MinQueryLength = value ? _fallbackSettings.MinQueryLength ?? _suggestedMinQueryLength ?? 0 : null;
Save();
OnPropertyChanged(nameof(HasMinQueryLengthOverride));
OnPropertyChanged(nameof(MinQueryLengthValue));
OnPropertyChanged(nameof(CanEditMinQueryLength));
}
}
public double MinQueryLengthValue
{
get => _fallbackSettings.MinQueryLength ?? _suggestedMinQueryLength ?? 0;
set
{
var normalizedValue = NormalizeUnsignedValue(value);
if (_fallbackSettings.MinQueryLength == normalizedValue)
{
return;
}
_fallbackSettings.MinQueryLength = normalizedValue;
Save();
OnPropertyChanged(nameof(MinQueryLengthValue));
OnPropertyChanged(nameof(HasMinQueryLengthOverride));
OnPropertyChanged(nameof(CanEditMinQueryLength));
}
}
public bool CanEditMinQueryLength => IsEnabled && HasMinQueryLengthOverride;
public string MinQueryLengthDescription => FormatSuggestedValueDescription(
"FallbackMinQueryLengthDescriptionWithSuggestion",
"FallbackMinQueryLengthDescriptionWithoutSuggestion",
_suggestedMinQueryLength);
public FallbackSettingsViewModel(
TopLevelViewModel fallback,
FallbackSettings fallbackSettings,
@@ -67,6 +192,8 @@ public partial class FallbackSettingsViewModel : ObservableObject
{
_settings = settingsModel;
_fallbackSettings = fallbackSettings;
_suggestedQueryDelayMilliseconds = fallback.GetSuggestedFallbackQueryDelayMilliseconds();
_suggestedMinQueryLength = fallback.GetSuggestedFallbackMinQueryLength();
Id = fallback.Id;
DisplayName = string.IsNullOrWhiteSpace(fallback.DisplayTitle)
@@ -82,4 +209,27 @@ public partial class FallbackSettingsViewModel : ObservableObject
SettingsModel.SaveSettings(_settings);
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
}
private static string FormatSuggestedValueDescription(string withSuggestionKey, string withoutSuggestionKey, uint? suggestedValue)
{
var resourceManager = Properties.Resources.ResourceManager;
var resourceKey = suggestedValue.HasValue ? withSuggestionKey : withoutSuggestionKey;
var format = resourceManager.GetString(resourceKey, CultureInfo.CurrentUICulture) ?? string.Empty;
return suggestedValue.HasValue ? string.Format(CultureInfo.CurrentCulture, format, suggestedValue.Value) : format;
}
private static uint NormalizeUnsignedValue(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value) || value <= 0)
{
return 0;
}
if (value >= uint.MaxValue)
{
return uint.MaxValue;
}
return (uint)Math.Round(value, MidpointRounding.AwayFromZero);
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed class FallbackSnapshotItemCache
{
private readonly Lock _lock = new();
private readonly AppExtensionHost _extensionHost;
private readonly ICommandProviderContext _providerContext;
private readonly string _fallbackSourceId;
private readonly string _extensionName;
private Dictionary<string, FallbackQueryResultItem> _currentItemsByIdentity = new(StringComparer.Ordinal);
internal FallbackSnapshotItemCache(
AppExtensionHost extensionHost,
ICommandProviderContext providerContext,
string fallbackSourceId,
string extensionName)
{
_extensionHost = extensionHost;
_providerContext = providerContext;
_fallbackSourceId = fallbackSourceId;
_extensionName = extensionName;
}
internal IListItem[] Materialize(
IReadOnlyList<FallbackSnapshotDefinition> snapshotItems,
string aliasText,
bool hasAlias,
IFallbackCommandInvocationArgs? invocationArgs,
Func<bool> isCurrent)
{
lock (_lock)
{
if (snapshotItems.Count == 0)
{
ClearUnderLock();
return [];
}
var identityCounts = new Dictionary<string, int>(snapshotItems.Count, StringComparer.Ordinal);
var nextItemsByIdentity = new Dictionary<string, FallbackQueryResultItem>(snapshotItems.Count, StringComparer.Ordinal);
var materializedItems = new IListItem[snapshotItems.Count];
for (var i = 0; i < snapshotItems.Count; i++)
{
var snapshotItem = snapshotItems[i];
var identity = CreateIdentity(snapshotItem, identityCounts);
if (_currentItemsByIdentity.TryGetValue(identity, out var existingItem))
{
existingItem.RefreshFromSource(
snapshotItem.SourceItem,
aliasText,
hasAlias,
invocationArgs,
isCurrent,
snapshotItem.ListenForSourceItemUpdates,
snapshotItem.TitleOverride,
snapshotItem.SubtitleOverride);
nextItemsByIdentity[identity] = existingItem;
materializedItems[i] = existingItem;
continue;
}
var item = new FallbackQueryResultItem(
identity,
snapshotItem.SourceItem,
_extensionHost,
_providerContext,
_fallbackSourceId,
_extensionName,
aliasText,
hasAlias,
invocationArgs,
isCurrent,
snapshotItem.ListenForSourceItemUpdates,
snapshotItem.TitleOverride,
snapshotItem.SubtitleOverride);
nextItemsByIdentity[identity] = item;
materializedItems[i] = item;
}
foreach (var pair in _currentItemsByIdentity)
{
if (!nextItemsByIdentity.ContainsKey(pair.Key))
{
pair.Value.DetachSourceItemUpdates();
}
}
_currentItemsByIdentity = nextItemsByIdentity;
return materializedItems;
}
}
internal void Clear()
{
lock (_lock)
{
ClearUnderLock();
}
}
private void ClearUnderLock()
{
foreach (var item in _currentItemsByIdentity.Values)
{
item.DetachSourceItemUpdates();
}
_currentItemsByIdentity.Clear();
}
private string CreateIdentity(FallbackSnapshotDefinition snapshotItem, Dictionary<string, int> identityCounts)
{
var baseIdentity = BuildIdentity(snapshotItem.SourceItem, snapshotItem.TitleOverride, snapshotItem.SubtitleOverride);
identityCounts.TryGetValue(baseIdentity, out var currentCount);
currentCount++;
identityCounts[baseIdentity] = currentCount;
return currentCount == 1
? baseIdentity
: $"{baseIdentity}#{currentCount.ToString(CultureInfo.InvariantCulture)}";
}
private string BuildIdentity(IListItem sourceItem, string? titleOverride, string? subtitleOverride)
{
var sourceIdentity = sourceItem is IObjectWithIdentity identifiable && !string.IsNullOrWhiteSpace(identifiable.Identity)
? identifiable.Identity
: sourceItem.Command is { Id: { Length: > 0 } commandId }
? commandId
: BuildSyntheticIdentity(sourceItem, titleOverride, subtitleOverride);
return $"{_fallbackSourceId}:{sourceIdentity}";
}
private static string BuildSyntheticIdentity(IListItem sourceItem, string? titleOverride, string? subtitleOverride)
{
var title = titleOverride ?? sourceItem.Title ?? string.Empty;
var subtitle = subtitleOverride ?? sourceItem.Subtitle ?? string.Empty;
var section = sourceItem.Section ?? string.Empty;
var textToSuggest = sourceItem.TextToSuggest ?? string.Empty;
return $"{sourceItem.GetType().FullName}|{title}|{subtitle}|{section}|{textToSuggest}";
}
}

View File

@@ -73,6 +73,25 @@ internal sealed partial class FallbackUpdateManager : IDisposable
Logger.LogDebug($"UpdateFallbacks: Batch start {batchNumber} for query '{query}'");
#endif
List<TopLevelViewModel> immediateCommands = [];
foreach (var command in commands)
{
var delay = command.GetFallbackExecutionPolicy().GetDelayForQuery(query);
if (delay > TimeSpan.Zero)
{
ScheduleDelayedFallbackUpdate(command, query, delay, cancellationToken);
}
else
{
immediateCommands.Add(command);
}
}
if (immediateCommands.Count == 0)
{
return;
}
// Adaptive dispatch on dedicated threads — same semantics as the old
// ParallelHelper.AdaptiveForEachAdaptiveAsync, but without any ThreadPool involvement:
// - Start 2 workers; each claims commands via a shared atomic index (FIFO, no double-work).
@@ -80,7 +99,7 @@ internal sealed partial class FallbackUpdateManager : IDisposable
// remaining fast commands aren't blocked waiting in the worker's loop.
// - _onFallbackChanged is called on the dedicated thread when a result changes
var sharedIndex = 0;
var totalCommands = commands.Count;
var totalCommands = immediateCommands.Count;
var startingWorkers = Math.Min(InitialFallbackWorkers, totalCommands);
var activeWorkerCount = startingWorkers;
@@ -94,16 +113,13 @@ internal sealed partial class FallbackUpdateManager : IDisposable
return;
}
var command = commands[i];
var command = immediateCommands[i];
var counter = _inflightFallbacks.GetOrAdd(command.Id, static _ => new InflightCounter());
if (!counter.TryClaim(MaxInflightPerFallback))
{
// At capacity — store this query as a pending retry so it runs
// when one of the in-flight calls finishes. Latest query wins.
var pendingCommand = command;
var pendingQuery = query;
var pendingCt = cancellationToken;
counter.SetPending(() => RetryFallbackUpdate(pendingCommand, pendingQuery, pendingCt, counter), pendingCt);
counter.SetPending(() => RetryFallbackUpdate(command, query, counter, cancellationToken), cancellationToken);
continue;
}
@@ -134,30 +150,7 @@ internal sealed partial class FallbackUpdateManager : IDisposable
var changed = false;
try
{
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
var sw = Stopwatch.StartNew();
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updating with '{query}'");
#endif
changed = command.SafeUpdateFallbackTextSynchronous(query);
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
var elapsed = sw.Elapsed;
var tail = elapsed > FallbackItemSlowTimeout ? " is slow" : string.Empty;
if (elapsed > FallbackItemUltraSlowTimeout)
{
tail += " <---------------- (ultra slow)";
}
if (cancellationToken.IsCancellationRequested)
{
return;
}
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updated with '{query}' processed in {elapsed}, has {(changed ? "changed" : "not changed")} and title is '{command.Title}'{tail}");
#endif
}
catch (Exception ex)
{
Logger.LogError($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' failed to update fallback text with '{query}'", ex);
changed = RunFallbackUpdate(command, query, cancellationToken);
}
finally
{
@@ -173,62 +166,128 @@ internal sealed partial class FallbackUpdateManager : IDisposable
}
}
// Dispatches a pending work item to the dedicated pool. The pending's
// own CT is forwarded so the pool can skip it at dequeue time when the
// originating query batch has been superseded by a newer keystroke.
void DispatchPending(PendingWork? pending)
{
if (pending == null)
{
return;
}
_ = _fallbackThreadPool.QueueAsync(pending.Work, pending.CancellationToken);
}
for (var i = 0; i < startingWorkers; i++)
{
_ = _fallbackThreadPool.QueueAsync(Worker, cancellationToken);
}
}
return;
private void ScheduleDelayedFallbackUpdate(TopLevelViewModel command, string query, TimeSpan delay, CancellationToken cancellationToken)
{
_ = Task.Run(
async () =>
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
// One-shot retry for a command that was skipped due to MaxInflightPerFallback.
// Claims a slot, runs the COM call, releases, and propagates the next pending (if any).
void RetryFallbackUpdate(TopLevelViewModel cmd, string q, CancellationToken ct, InflightCounter ctr)
if (cancellationToken.IsCancellationRequested)
{
return;
}
var counter = _inflightFallbacks.GetOrAdd(command.Id, static _ => new InflightCounter());
if (!counter.TryClaim(MaxInflightPerFallback))
{
counter.SetPending(() => RetryFallbackUpdate(command, query, counter, cancellationToken), cancellationToken);
return;
}
try
{
var changed = RunFallbackUpdate(command, query, cancellationToken);
if (changed && !cancellationToken.IsCancellationRequested)
{
_onFallbackChanged();
}
}
finally
{
counter.Release();
DispatchPending(counter.TakePending());
}
},
CancellationToken.None);
}
// Dispatches a pending work item to the dedicated pool. The pending's
// own CT is forwarded so the pool can skip it at dequeue time when the
// originating query batch has been superseded by a newer keystroke.
private void DispatchPending(PendingWork? pending)
{
if (pending == null)
{
if (ct.IsCancellationRequested)
{
return;
}
return;
}
if (!ctr.TryClaim(MaxInflightPerFallback))
{
// Still at capacity (a newer worker claimed the freed slot first).
// The pending was already consumed from TakePending, so it's dropped here.
return;
}
_ = _fallbackThreadPool.QueueAsync(pending.Work, pending.CancellationToken);
}
var changed = false;
try
{
changed = cmd.SafeUpdateFallbackTextSynchronous(q);
}
catch (Exception ex)
{
Logger.LogError($"UpdateFallbacks: Pending retry: command id '{cmd.Id}', '{cmd.DisplayTitle}' failed with '{q}'", ex);
}
finally
{
ctr.Release();
DispatchPending(ctr.TakePending());
}
// One-shot retry for a command that was skipped due to MaxInflightPerFallback.
// Claims a slot, runs the COM call, releases, and propagates the next pending (if any).
private void RetryFallbackUpdate(TopLevelViewModel command, string query, InflightCounter counter, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (changed && !ct.IsCancellationRequested)
if (!counter.TryClaim(MaxInflightPerFallback))
{
// Still at capacity (a newer worker claimed the freed slot first).
// The pending was already consumed from TakePending, so it's dropped here.
return;
}
try
{
var changed = RunFallbackUpdate(command, query, cancellationToken);
if (changed && !cancellationToken.IsCancellationRequested)
{
_onFallbackChanged();
}
}
finally
{
counter.Release();
DispatchPending(counter.TakePending());
}
}
private static bool RunFallbackUpdate(TopLevelViewModel command, string query, CancellationToken cancellationToken)
{
try
{
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
var sw = Stopwatch.StartNew();
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updating with '{query}'");
#endif
var changed = command.SafeUpdateFallbackTextSynchronous(query);
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
var elapsed = sw.Elapsed;
var tail = elapsed > FallbackItemSlowTimeout ? " is slow" : string.Empty;
if (elapsed > FallbackItemUltraSlowTimeout)
{
tail += " <---------------- (ultra slow)";
}
if (!cancellationToken.IsCancellationRequested)
{
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updated with '{query}' processed in {elapsed}, has {(changed ? "changed" : "not changed")} and title is '{command.Title}'{tail}");
}
#endif
return changed;
}
catch (Exception ex)
{
Logger.LogError($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' failed to update fallback text with '{query}'", ex);
return false;
}
}
public void Dispose()

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public interface IFallbackResultItem
{
string FallbackSourceId { get; }
string ExtensionName { get; }
bool HasAlias { get; }
string AliasText { get; }
AppExtensionHost ExtensionHost { get; }
ICommandProviderContext ProviderContext { get; }
IFallbackCommandInvocationArgs? InvocationArgs { get; }
bool IsCurrent { get; }
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
internal static class FallbackRenderBlockFactory
{
internal static FallbackRenderBlock Create(
IReadOnlyList<IListItem> sourceItems,
bool treatAsGlobal,
int score,
bool showResultsInDedicatedSection,
bool showBeforeMainResults,
Separator? sectionSeparator,
in FuzzyQuery searchQuery,
ScoringFunction<IListItem> scoringFunction)
{
var visibleItems = sourceItems
.Where(item => !string.IsNullOrWhiteSpace(item.Title))
.ToArray();
if (visibleItems.Length == 0)
{
return FallbackRenderBlock.Empty;
}
if (showBeforeMainResults)
{
return new FallbackRenderBlock(
LeadingItems: CreateSectionItems(sectionSeparator, visibleItems),
ScoredGlobalItems: [],
TrailingGlobalItems: [],
OrderedFallbackItems: []);
}
if (treatAsGlobal)
{
return showResultsInDedicatedSection
? new FallbackRenderBlock(
LeadingItems: [],
ScoredGlobalItems: [],
TrailingGlobalItems: CreateSectionItems(sectionSeparator, visibleItems),
OrderedFallbackItems: [])
: new FallbackRenderBlock(
LeadingItems: [],
ScoredGlobalItems: [.. InternalListHelpers.FilterListWithScores(visibleItems, searchQuery, scoringFunction)],
TrailingGlobalItems: [],
OrderedFallbackItems: []);
}
var orderedItems = new List<RoScored<IListItem>>(visibleItems.Length + (showResultsInDedicatedSection && sectionSeparator is not null ? 1 : 0));
if (showResultsInDedicatedSection && sectionSeparator is not null)
{
orderedItems.Add(new(sectionSeparator, score));
}
foreach (var item in visibleItems)
{
orderedItems.Add(new(item, score));
}
return new FallbackRenderBlock(
LeadingItems: [],
ScoredGlobalItems: [],
TrailingGlobalItems: [],
OrderedFallbackItems: [.. orderedItems]);
}
private static IListItem[] CreateSectionItems(Separator? sectionSeparator, IReadOnlyList<IListItem> items)
{
if (sectionSeparator is null)
{
return [.. items];
}
var sectionItems = new IListItem[items.Count + 1];
sectionItems[0] = sectionSeparator;
for (var i = 0; i < items.Count; i++)
{
sectionItems[i + 1] = items[i];
}
return sectionItems;
}
}
internal readonly record struct FallbackRenderBlock(
IListItem[] LeadingItems,
RoScored<IListItem>[] ScoredGlobalItems,
IListItem[] TrailingGlobalItems,
RoScored<IListItem>[] OrderedFallbackItems)
{
internal static readonly FallbackRenderBlock Empty = new([], [], [], []);
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -16,6 +17,8 @@ public record PerformCommandMessage
public object? Context { get; }
public IFallbackCommandInvocationArgs? FallbackCommandInvocationArgs { get; }
public bool WithAnimation { get; set; } = true;
public bool TransientPage { get; set; }
@@ -30,18 +33,28 @@ public record PerformCommandMessage
{
Command = command;
Context = context.Unsafe;
FallbackCommandInvocationArgs = (context.Unsafe as IFallbackResultItem)?.InvocationArgs;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context, IFallbackCommandInvocationArgs? fallbackCommandInvocationArgs)
{
Command = command;
Context = context.Unsafe;
FallbackCommandInvocationArgs = fallbackCommandInvocationArgs ?? (context.Unsafe as IFallbackResultItem)?.InvocationArgs;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<ICommandItem> context)
{
Command = command;
Context = context.Unsafe;
FallbackCommandInvocationArgs = (context.Unsafe as IFallbackResultItem)?.InvocationArgs;
}
public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<ICommandContextItem> context)
{
Command = command;
Context = context.Unsafe;
FallbackCommandInvocationArgs = (context.Unsafe as IFallbackResultItem)?.InvocationArgs;
}
public PerformCommandMessage(CommandContextItemViewModel contextCommand)

View File

@@ -519,6 +519,42 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Skip this fallback on very short queries..
/// </summary>
public static string FallbackMinQueryLengthDescriptionWithoutSuggestion {
get {
return ResourceManager.GetString("FallbackMinQueryLengthDescriptionWithoutSuggestion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Skip this fallback on very short queries. Suggested default: {0} characters..
/// </summary>
public static string FallbackMinQueryLengthDescriptionWithSuggestion {
get {
return ResourceManager.GetString("FallbackMinQueryLengthDescriptionWithSuggestion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delay expensive fallback queries when this fallback needs extra time..
/// </summary>
public static string FallbackQueryDelayDescriptionWithoutSuggestion {
get {
return ResourceManager.GetString("FallbackQueryDelayDescriptionWithoutSuggestion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delay expensive fallback queries. Suggested default: {0} ms..
/// </summary>
public static string FallbackQueryDelayDescriptionWithSuggestion {
get {
return ResourceManager.GetString("FallbackQueryDelayDescriptionWithSuggestion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fallbacks.
/// </summary>

View File

@@ -299,4 +299,16 @@
<data name="home_sections_commands_title" xml:space="preserve">
<value>Commands</value>
</data>
</root>
<data name="FallbackQueryDelayDescriptionWithSuggestion" xml:space="preserve">
<value>Delay expensive fallback queries. Suggested default: {0} ms.</value>
</data>
<data name="FallbackQueryDelayDescriptionWithoutSuggestion" xml:space="preserve">
<value>Delay expensive fallback queries when this fallback needs extra time.</value>
</data>
<data name="FallbackMinQueryLengthDescriptionWithSuggestion" xml:space="preserve">
<value>Skip this fallback on very short queries. Suggested default: {0} characters.</value>
</data>
<data name="FallbackMinQueryLengthDescriptionWithoutSuggestion" xml:space="preserve">
<value>Skip this fallback on very short queries.</value>
</data>
</root>

View File

@@ -1,19 +1,27 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public class ProviderSettings
{
// List of built-in fallbacks that should not have global results enabled by default
private readonly string[] _excludedBuiltInFallbacks = [
private static readonly string[] ExcludedBuiltInFallbacks = [
"com.microsoft.cmdpal.builtin.indexer.fallback",
"com.microsoft.cmdpal.builtin.calculator.fallback",
];
private static readonly Dictionary<string, FallbackSettingsDefaults> DefaultFallbackSettings = new(StringComparer.Ordinal)
{
["com.microsoft.cmdpal.builtin.indexer.fallback"] = new(QueryDelayMilliseconds: 120, MinQueryLength: 2),
["com.microsoft.cmdpal.builtin.remotedesktop.fallback"] = new(QueryDelayMilliseconds: 75, MinQueryLength: 2),
["com.microsoft.cmdpal.builtin.shell.fallback"] = new(QueryDelayMilliseconds: 100, MinQueryLength: 2),
};
public bool IsEnabled { get; set; } = true;
public Dictionary<string, FallbackSettings> FallbackCommands { get; set; } = new();
@@ -51,10 +59,11 @@ public class ProviderSettings
{
foreach (var fallback in wrapper.FallbackItems)
{
if (!FallbackCommands.ContainsKey(fallback.Id))
if (!FallbackCommands.TryGetValue(fallback.Id, out var fallbackSettings))
{
var enableGlobalResults = IsBuiltin && !_excludedBuiltInFallbacks.Contains(fallback.Id);
FallbackCommands[fallback.Id] = new FallbackSettings(enableGlobalResults);
var enableGlobalResults = IsBuiltin && !ExcludedBuiltInFallbacks.Contains(fallback.Id);
fallbackSettings = new FallbackSettings(enableGlobalResults);
FallbackCommands[fallback.Id] = fallbackSettings;
}
}
}
@@ -64,4 +73,34 @@ public class ProviderSettings
throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!");
}
}
internal static uint? GetSuggestedFallbackQueryDelayMilliseconds(string fallbackId, IFallbackCommandItemDefaults? defaults = null)
{
if (defaults is not null && defaults.SuggestedQueryDelayMilliseconds.HasValue)
{
return defaults.SuggestedQueryDelayMilliseconds.Value;
}
return DefaultFallbackSettings.TryGetValue(fallbackId, out var fallbackDefaults) ? fallbackDefaults.QueryDelayMilliseconds : null;
}
internal static uint? GetSuggestedFallbackMinQueryLength(string fallbackId, IFallbackCommandItemDefaults? defaults = null)
{
if (defaults is not null && defaults.SuggestedMinQueryLength.HasValue)
{
return defaults.SuggestedMinQueryLength.Value;
}
return DefaultFallbackSettings.TryGetValue(fallbackId, out var fallbackDefaults) ? fallbackDefaults.MinQueryLength : null;
}
internal static FallbackExecutionPolicy GetEffectiveFallbackExecutionPolicy(string fallbackId, FallbackSettings? fallbackSettings, IFallbackCommandItemDefaults? defaults = null)
{
var delayMilliseconds = fallbackSettings?.QueryDelayMilliseconds ?? GetSuggestedFallbackQueryDelayMilliseconds(fallbackId, defaults);
var minQueryLength = fallbackSettings?.MinQueryLength ?? GetSuggestedFallbackMinQueryLength(fallbackId, defaults);
var delay = delayMilliseconds is uint value ? TimeSpan.FromMilliseconds(value) : TimeSpan.Zero;
return new FallbackExecutionPolicy(delay, minQueryLength ?? 0);
}
private readonly record struct FallbackSettingsDefaults(uint? QueryDelayMilliseconds, uint? MinQueryLength);
}

View File

@@ -261,6 +261,21 @@ public partial class ShellViewModel : ObservableObject,
return;
}
if (message.Context is IFallbackResultItem fallbackResultItem && !fallbackResultItem.IsCurrent)
{
FallbackRefreshNotifier.RequestRefresh();
return;
}
if (message.Context is TopLevelViewModel topLevelFallback &&
topLevelFallback.IsFallback &&
message.FallbackCommandInvocationArgs is not null &&
!topLevelFallback.IsCurrentFallbackQueryId(message.FallbackCommandInvocationArgs.QueryId))
{
FallbackRefreshNotifier.RequestRefresh();
return;
}
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var providerContext = _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
@@ -372,7 +387,12 @@ public partial class ShellViewModel : ObservableObject,
// Call out to extension process.
// * May fail!
// * May never return!
var result = invokable.Invoke(message.Context);
var result = invokable switch
{
IInvokableCommand2 invokable2 when message.FallbackCommandInvocationArgs is not null
=> invokable2.InvokeWithArgs(message.Context, message.FallbackCommandInvocationArgs),
_ => invokable.Invoke(message.Context),
};
// But if it did succeed, we need to handle the result.
UnsafeHandleCommandResult(result);

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -32,7 +32,6 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id;
private string _fallbackId = string.Empty;
private string _generatedId = string.Empty;
private HotkeySettings? _hotkey;
@@ -41,6 +40,11 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
private FuzzyTargetCache _extensionNameCache;
private FallbackExecutionState? _fallbackExecutionState;
private IFallbackCommandItemDefaults? _fallbackCommandItemDefaults;
private FallbackSnapshotItemCache? _fallbackSnapshotItemCache;
private bool _usesAsyncFallbackEvaluation;
private HostMatchKind? _fallbackHostMatchKind;
private CommandAlias? Alias { get; set; }
@@ -91,6 +95,15 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
// Fallback items
public string DisplayTitle { get; private set; } = string.Empty;
internal bool UsesInlineFallbackEvaluation
{
get
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _fallbackExecutionState?.UsesInlineEvaluation ?? false;
}
}
public HotkeySettings? Hotkey
{
get => _hotkey;
@@ -245,17 +258,43 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
if (IsFallback)
{
var model = _commandItemViewModel.Model.Unsafe;
// RPC to check type
if (model is IFallbackCommandItem fallback)
{
DisplayTitle = fallback.DisplayTitle;
}
CacheFallbackInterfaces(model);
UpdateInitialIcon(false);
}
}
private void CacheFallbackInterfaces(object? model)
{
if (!IsFallback || _fallbackExecutionState is not null || model is not IFallbackCommandItem fallback)
{
return;
}
if (model is IFallbackCommandItem2 fallback2)
{
_fallbackId = fallback2.Id;
}
var asyncFallbackHandler = fallback.FallbackHandler as IFallbackHandler2;
var hostMatchedFallbackCommandItem = model as IHostMatchedFallbackCommandItem;
_fallbackExecutionState = new FallbackExecutionState(
fallback,
asyncFallbackHandler,
model as IFormattedFallbackCommandItem,
hostMatchedFallbackCommandItem,
GetFallbackExecutionPolicy,
MaterializeSnapshotItems,
FallbackRefreshNotifier.RequestRefresh);
_fallbackCommandItemDefaults = model as IFallbackCommandItemDefaults;
_fallbackSnapshotItemCache = new FallbackSnapshotItemCache(ExtensionHost, ProviderContext, Id, ExtensionName);
_usesAsyncFallbackEvaluation = asyncFallbackHandler is not null;
_fallbackHostMatchKind = hostMatchedFallbackCommandItem?.MatchKind;
DisplayTitle = fallback.DisplayTitle;
}
private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!string.IsNullOrEmpty(e.PropertyName))
@@ -398,55 +437,159 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
internal bool SafeUpdateFallbackTextSynchronous(string newQuery)
{
if (!IsFallback)
if (!IsFallback || !IsEnabled)
{
return false;
}
if (!IsEnabled)
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
if (_fallbackExecutionState is null)
{
return false;
}
try
{
return UnsafeUpdateFallbackSynchronous(newQuery);
return _fallbackExecutionState.UpdateSynchronous(newQuery, this);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
return false;
}
return false;
}
/// <summary>
/// Calls UpdateQuery on our command, if we're a fallback item. This does
/// RPC work, so make sure you're calling it on a BG thread.
/// </summary>
/// <param name="newQuery">The new search text to pass to the extension</param>
/// <returns>true if our Title changed across this call</returns>
private bool UnsafeUpdateFallbackSynchronous(string newQuery)
internal bool SafeUpdateFallbackTextInline(string newQuery)
{
var model = _commandItemViewModel.Model.Unsafe;
// RPC to check type
if (model is IFallbackCommandItem fallback)
if (!IsFallback || !IsEnabled)
{
var wasEmpty = string.IsNullOrEmpty(Title);
// RPC for method
fallback.FallbackHandler.UpdateQuery(newQuery);
var isEmpty = string.IsNullOrEmpty(Title);
return wasEmpty != isEmpty;
return false;
}
return false;
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
if (_fallbackExecutionState is null)
{
return false;
}
try
{
return _fallbackExecutionState.UpdateInline(newQuery, this);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
return false;
}
}
internal IListItem[] GetCurrentFallbackItems()
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _fallbackExecutionState?.GetCurrentItems() ?? [];
}
internal void CancelOutstandingFallbackQuery()
{
if (!IsFallback)
{
return;
}
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
_fallbackExecutionState?.CancelOutstandingQuery();
_fallbackSnapshotItemCache?.Clear();
}
internal bool IsCurrentFallbackQueryId(string queryId)
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _fallbackExecutionState?.IsCurrentQueryId(queryId) ?? false;
}
internal bool UsesAsyncFallbackEvaluation
{
get
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _usesAsyncFallbackEvaluation;
}
}
internal HostMatchKind? FallbackHostMatchKind
{
get
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _fallbackHostMatchKind;
}
}
internal FallbackExecutionPolicy GetFallbackExecutionPolicy()
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
if (string.IsNullOrEmpty(_fallbackId))
{
return FallbackExecutionPolicy.Empty;
}
_providerSettings.FallbackCommands.TryGetValue(_fallbackId, out var fallbackSettings);
return ProviderSettings.GetEffectiveFallbackExecutionPolicy(_fallbackId, fallbackSettings, _fallbackCommandItemDefaults);
}
internal uint? GetSuggestedFallbackQueryDelayMilliseconds()
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return string.IsNullOrEmpty(_fallbackId)
? null
: ProviderSettings.GetSuggestedFallbackQueryDelayMilliseconds(_fallbackId, _fallbackCommandItemDefaults);
}
internal uint? GetSuggestedFallbackMinQueryLength()
{
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return string.IsNullOrEmpty(_fallbackId)
? null
: ProviderSettings.GetSuggestedFallbackMinQueryLength(_fallbackId, _fallbackCommandItemDefaults);
}
private IListItem[] MaterializeSnapshotItems(string query, string queryId, IReadOnlyList<FallbackSnapshotDefinition> snapshotItems)
{
_fallbackSnapshotItemCache ??= new FallbackSnapshotItemCache(ExtensionHost, ProviderContext, Id, ExtensionName);
var invocationArgs = new FallbackCommandInvocationArgs()
{
Query = query,
QueryId = queryId,
};
return _fallbackSnapshotItemCache.Materialize(
snapshotItems,
AliasText,
HasAlias,
invocationArgs,
() => IsCurrentFallbackQueryId(queryId));
}
internal static bool IsRegexMatch(string pattern, string query)
{
return FallbackExecutionState.IsRegexMatch(pattern, query);
}
public PerformCommandMessage GetPerformCommandMessage()
{
return new PerformCommandMessage(this.CommandViewModel.Model, new Models.ExtensionObject<IListItem>(this));
return new PerformCommandMessage(this.CommandViewModel.Model, new Models.ExtensionObject<IListItem>(this), GetCurrentFallbackInvocationArgs());
}
private IFallbackCommandInvocationArgs? GetCurrentFallbackInvocationArgs()
{
if (!IsFallback)
{
return null;
}
CacheFallbackInterfaces(_commandItemViewModel.Model.Unsafe);
return _fallbackExecutionState?.GetCurrentInvocationArgs();
}
public override string ToString()

View File

@@ -21,7 +21,7 @@
<!-- For MVVM Toolkit Partial Properties/AOT support -->
<LangVersion>preview</LangVersion>
<!-- OutputPath is set in CmdPal.Branding.props -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -39,10 +39,10 @@
</PropertyGroup>
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
<!-- <PropertyGroup>
<PropertyGroup>
<EnableCmdPalAOT>true</EnableCmdPalAOT>
<GeneratePackageLocally>true</GeneratePackageLocally>
</PropertyGroup> -->
</PropertyGroup>
<PropertyGroup Condition="'$(EnableCmdPalAOT)' == 'true'">
<SelfContained>true</SelfContained>

View File

@@ -22,6 +22,10 @@ internal sealed class PowerToysAppHostService : IAppHostService
{
topLevelHost = topLevelViewModel.ExtensionHost;
}
else if (context is IFallbackResultItem fallbackResultItem)
{
topLevelHost = fallbackResultItem.ExtensionHost;
}
return topLevelHost ?? currentHost ?? CommandPaletteHost.Instance;
}
@@ -33,6 +37,10 @@ internal sealed class PowerToysAppHostService : IAppHostService
{
topLevelId = topLevelViewModel.ProviderContext;
}
else if (command is IFallbackResultItem fallbackResultItem)
{
topLevelId = fallbackResultItem.ProviderContext;
}
return topLevelId ?? currentContext ?? throw new InvalidOperationException("No command provider context could be found for the given command, and no current context was provided.");
}

View File

@@ -180,6 +180,66 @@
IsChecked="{x:Bind IncludeInGlobalResults, Mode=TwoWay}"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}" />
</controls:SettingsCard>
<controls:SettingsCard ContentAlignment="Left">
<ptcontrols:CheckBoxWithDescriptionControl
x:Uid="Settings_FallbacksPage_SeparateSection_SettingsCard"
IsChecked="{x:Bind ShowResultsInDedicatedSection, Mode=TwoWay}"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}" />
</controls:SettingsCard>
<controls:SettingsCard ContentAlignment="Left">
<ptcontrols:CheckBoxWithDescriptionControl
x:Uid="Settings_FallbacksPage_BeforeResults_SettingsCard"
IsChecked="{x:Bind ShowResultsBeforeMainResults, Mode=TwoWay}"
IsEnabled="{x:Bind CanShowResultsBeforeMainResults, Mode=OneWay}" />
</controls:SettingsCard>
<controls:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind IsEnabled, Mode=OneWay}">
<controls:SettingsCard.Header>
<TextBlock x:Uid="Settings_FallbacksPage_QueryDelay_SettingsCard.Header" />
</controls:SettingsCard.Header>
<controls:SettingsCard.Description>
<TextBlock Text="{x:Bind QueryDelayDescription, Mode=OneWay}" TextWrapping="WrapWholeWords" />
</controls:SettingsCard.Description>
<StackPanel Spacing="12">
<ToggleSwitch
x:Uid="Settings_FallbacksPage_QueryDelayOverride_ToggleSwitch"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}"
IsOn="{x:Bind HasQueryDelayOverride, Mode=TwoWay}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<NumberBox
Width="140"
IsEnabled="{x:Bind CanEditQueryDelayMilliseconds, Mode=OneWay}"
Minimum="0"
SmallChange="25"
SpinButtonPlacementMode="Inline"
Value="{x:Bind QueryDelayMillisecondsValue, Mode=TwoWay}" />
<TextBlock x:Uid="Settings_FallbacksPage_QueryDelayUnits_TextBlock" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
</controls:SettingsCard>
<controls:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind IsEnabled, Mode=OneWay}">
<controls:SettingsCard.Header>
<TextBlock x:Uid="Settings_FallbacksPage_MinQueryLength_SettingsCard.Header" />
</controls:SettingsCard.Header>
<controls:SettingsCard.Description>
<TextBlock Text="{x:Bind MinQueryLengthDescription, Mode=OneWay}" TextWrapping="WrapWholeWords" />
</controls:SettingsCard.Description>
<StackPanel Spacing="12">
<ToggleSwitch
x:Uid="Settings_FallbacksPage_MinQueryLengthOverride_ToggleSwitch"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}"
IsOn="{x:Bind HasMinQueryLengthOverride, Mode=TwoWay}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<NumberBox
Width="140"
IsEnabled="{x:Bind CanEditMinQueryLength, Mode=OneWay}"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Inline"
Value="{x:Bind MinQueryLengthValue, Mode=TwoWay}" />
<TextBlock x:Uid="Settings_FallbacksPage_MinQueryLengthUnits_TextBlock" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@@ -742,6 +742,42 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_FallbacksPage_GlobalResults_SettingsCard.Description" xml:space="preserve">
<value>Show results on queries without direct activation command</value>
</data>
<data name="Settings_FallbacksPage_SeparateSection_SettingsCard.Header" xml:space="preserve">
<value>Show in a separate section</value>
</data>
<data name="Settings_FallbacksPage_SeparateSection_SettingsCard.Description" xml:space="preserve">
<value>Group this fallback&apos;s results under its own section header</value>
</data>
<data name="Settings_FallbacksPage_BeforeResults_SettingsCard.Header" xml:space="preserve">
<value>Show before main results</value>
</data>
<data name="Settings_FallbacksPage_BeforeResults_SettingsCard.Description" xml:space="preserve">
<value>Place this dedicated fallback section ahead of the main results list</value>
</data>
<data name="Settings_FallbacksPage_QueryDelay_SettingsCard.Header" xml:space="preserve">
<value>Delay before querying</value>
</data>
<data name="Settings_FallbacksPage_QueryDelayOverride_ToggleSwitch.OnContent" xml:space="preserve">
<value>Use custom delay</value>
</data>
<data name="Settings_FallbacksPage_QueryDelayOverride_ToggleSwitch.OffContent" xml:space="preserve">
<value>Use suggested default</value>
</data>
<data name="Settings_FallbacksPage_QueryDelayUnits_TextBlock.Text" xml:space="preserve">
<value>ms</value>
</data>
<data name="Settings_FallbacksPage_MinQueryLength_SettingsCard.Header" xml:space="preserve">
<value>Minimum query length</value>
</data>
<data name="Settings_FallbacksPage_MinQueryLengthOverride_ToggleSwitch.OnContent" xml:space="preserve">
<value>Use custom minimum</value>
</data>
<data name="Settings_FallbacksPage_MinQueryLengthOverride_ToggleSwitch.OffContent" xml:space="preserve">
<value>Use suggested default</value>
</data>
<data name="Settings_FallbacksPage_MinQueryLengthUnits_TextBlock.Text" xml:space="preserve">
<value>characters</value>
</data>
<data name="ManageFallbackRankAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Manage fallback order</value>
</data>
@@ -949,4 +985,4 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="FiltersDropDown_NoResults.Text" xml:space="preserve">
<value>No results</value>
</data>
</root>
</root>

View File

@@ -0,0 +1,373 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class FallbackPrototypeTests
{
[TestMethod]
public void ScoreTopLevelItem_AppliesFallbackPenaltyToWrappedFallbackItems()
{
var matcher = new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
var query = matcher.PrecomputeQuery("calc");
var history = new RecentCommandsManager();
var direct = new MockListItem("calc");
var fallback = new MockFallbackListItem(
title: "calc",
extensionName: "Web Search",
fallbackSourceId: "com.microsoft.cmdpal.websearch",
invocationArgs: new FallbackCommandInvocationArgs { Query = "calc", QueryId = "1" });
var directScore = MainListPage.ScoreTopLevelItem(query, direct, history, matcher);
var fallbackScore = MainListPage.ScoreTopLevelItem(query, fallback, history, matcher);
Assert.IsTrue(directScore > fallbackScore, "Wrapped fallback items should still rank below direct matches.");
}
[TestMethod]
public void PerformCommandMessage_CarriesFallbackInvocationArgsFromListContext()
{
var command = new NoOpCommand() { Id = "command" };
var args = new FallbackCommandInvocationArgs { Query = "search term", QueryId = "query-7" };
var item = new MockFallbackListItem(
title: "Search web for \"search term\"",
extensionName: "Web Search",
fallbackSourceId: "com.microsoft.cmdpal.websearch",
invocationArgs: args,
command: command);
var message = new PerformCommandMessage(
new ExtensionObject<ICommand>(command),
new ExtensionObject<IListItem>(item));
Assert.IsNotNull(message.FallbackCommandInvocationArgs);
Assert.AreEqual(args.Query, message.FallbackCommandInvocationArgs!.Query);
Assert.AreEqual(args.QueryId, message.FallbackCommandInvocationArgs.QueryId);
}
[TestMethod]
public void PerformCommandMessage_UsesExplicitFallbackInvocationArgs()
{
var command = new NoOpCommand() { Id = "command" };
var args = new FallbackCommandInvocationArgs { Query = "fresh query", QueryId = "query-9" };
var item = new MockListItem("Search web");
var message = new PerformCommandMessage(
new ExtensionObject<ICommand>(command),
new ExtensionObject<IListItem>(item),
args);
Assert.IsNotNull(message.FallbackCommandInvocationArgs);
Assert.AreEqual(args.Query, message.FallbackCommandInvocationArgs!.Query);
Assert.AreEqual(args.QueryId, message.FallbackCommandInvocationArgs.QueryId);
}
[TestMethod]
public void IsRegexMatch_RequiresFullMatch()
{
Assert.IsTrue(TopLevelViewModel.IsRegexMatch(@"foo\d+", "foo123"));
Assert.IsFalse(TopLevelViewModel.IsRegexMatch(@"foo\d+", "prefix foo123 suffix"));
}
[TestMethod]
public void IsRegexMatch_ReturnsFalseForInvalidPattern()
{
Assert.IsFalse(TopLevelViewModel.IsRegexMatch("(", "foo"));
}
[TestMethod]
public void FallbackQueryResultItem_PreservesViewModelIconWithoutCasting()
{
var source = new MockListItem("Open URL");
var icon = new IconInfoViewModel(new IconInfo("\uE71B"));
source.Icon = icon;
var wrapped = new FallbackQueryResultItem(
"com.microsoft.cmdpal.websearch:open-url",
source,
new MockHost(),
CommandProviderContext.Empty,
"com.microsoft.cmdpal.websearch",
"Web Search",
string.Empty,
hasAlias: false,
new FallbackCommandInvocationArgs { Query = "https://example.com", QueryId = "query-1" },
isCurrent: () => true);
Assert.AreSame(icon, wrapped.Icon);
}
[TestMethod]
public void FallbackQueryResultItem_UpdatesIconWhenSourceItemChanges()
{
var source = new MockListItem("Open file")
{
Icon = new IconInfo("\uE8A5"),
};
var wrapped = new FallbackQueryResultItem(
"com.microsoft.cmdpal.indexer:file",
source,
new MockHost(),
CommandProviderContext.Empty,
"com.microsoft.cmdpal.indexer",
"Indexer",
string.Empty,
hasAlias: false,
new FallbackCommandInvocationArgs { Query = "file", QueryId = "query-2" },
isCurrent: () => true,
listenForSourceItemUpdates: true);
var updatedIcon = new IconInfo("\uE8A5");
source.Icon = updatedIcon;
Assert.AreSame(updatedIcon, wrapped.Icon);
}
[TestMethod]
public void ProviderSettings_GetSuggestedFallbackQueryDelayMilliseconds_PrefersApiSuggestion()
{
var fallback = new DefaultsFallbackListItem("com.microsoft.cmdpal.builtin.indexer.fallback")
{
SuggestedQueryDelayMilliseconds = new OptionalUInt32(true, 200),
};
Assert.AreEqual(200u, ProviderSettings.GetSuggestedFallbackQueryDelayMilliseconds(fallback.Id, fallback));
}
[TestMethod]
public void ProviderSettings_GetSuggestedFallbackQueryDelayMilliseconds_FallsBackToHostDefault()
{
Assert.AreEqual(120u, ProviderSettings.GetSuggestedFallbackQueryDelayMilliseconds("com.microsoft.cmdpal.builtin.indexer.fallback"));
}
[TestMethod]
public void ProviderSettings_GetSuggestedFallbackMinQueryLength_PrefersApiSuggestion()
{
var fallback = new DefaultsFallbackListItem("com.microsoft.cmdpal.builtin.indexer.fallback")
{
SuggestedMinQueryLength = new OptionalUInt32(true, 3),
};
Assert.AreEqual(3u, ProviderSettings.GetSuggestedFallbackMinQueryLength(fallback.Id, fallback));
}
[TestMethod]
public void ProviderSettings_GetEffectiveFallbackExecutionPolicy_PrefersExplicitOverrides()
{
var fallback = new DefaultsFallbackListItem("com.microsoft.cmdpal.builtin.indexer.fallback")
{
SuggestedQueryDelayMilliseconds = new OptionalUInt32(true, 120),
SuggestedMinQueryLength = new OptionalUInt32(true, 2),
};
var settings = new FallbackSettings
{
QueryDelayMilliseconds = 25,
MinQueryLength = 4,
};
var policy = ProviderSettings.GetEffectiveFallbackExecutionPolicy(fallback.Id, settings, fallback);
Assert.AreEqual(System.TimeSpan.FromMilliseconds(25), policy.Delay);
Assert.AreEqual(4u, policy.MinQueryLength);
}
[TestMethod]
public void ProviderSettings_GetEffectiveFallbackExecutionPolicy_UsesSuggestedDefaultsWhenOverridesAreMissing()
{
var fallback = new DefaultsFallbackListItem("com.microsoft.cmdpal.builtin.indexer.fallback")
{
SuggestedQueryDelayMilliseconds = new OptionalUInt32(true, 90),
SuggestedMinQueryLength = new OptionalUInt32(true, 3),
};
var policy = ProviderSettings.GetEffectiveFallbackExecutionPolicy(fallback.Id, fallbackSettings: null, fallback);
Assert.AreEqual(System.TimeSpan.FromMilliseconds(90), policy.Delay);
Assert.AreEqual(3u, policy.MinQueryLength);
}
[TestMethod]
public void FallbackExecutionState_SuppressesShortQueriesBeforeInvokingFallback()
{
var fallbackItem = new CountingFallbackListItem();
var state = new FallbackExecutionState(
fallbackItem,
asyncFallbackHandler: null,
formattedFallbackCommandItem: null,
hostMatchedFallbackCommandItem: null,
getPolicy: static () => new FallbackExecutionPolicy(System.TimeSpan.Zero, 2),
materializeSnapshotItems: static (query, queryId, snapshotItems) => snapshotItems.Select(item => (IListItem)new MockListItem(item.TitleOverride ?? item.SourceItem.Title)).ToArray(),
requestRefresh: static () => { });
var shortQueryChanged = state.UpdateSynchronous("a", fallbackItem);
var validQueryChanged = state.UpdateSynchronous("ab", fallbackItem);
Assert.IsFalse(shortQueryChanged);
Assert.AreEqual(1, fallbackItem.UpdateQueryCallCount);
Assert.IsTrue(validQueryChanged);
Assert.AreEqual(1, state.GetCurrentItems().Length);
}
[TestMethod]
public void FallbackExecutionState_LegacyFallbackSnapshotsListenForSourceItemUpdates()
{
var fallbackItem = new CountingFallbackListItem();
var listenedForSourceUpdates = false;
var state = new FallbackExecutionState(
fallbackItem,
asyncFallbackHandler: null,
formattedFallbackCommandItem: null,
hostMatchedFallbackCommandItem: null,
getPolicy: static () => FallbackExecutionPolicy.Empty,
materializeSnapshotItems: (query, queryId, snapshotItems) =>
{
listenedForSourceUpdates = snapshotItems[0].ListenForSourceItemUpdates;
return [new MockListItem(snapshotItems[0].TitleOverride ?? snapshotItems[0].SourceItem.Title)];
},
requestRefresh: static () => { });
state.UpdateSynchronous("icon-test", fallbackItem);
Assert.IsTrue(listenedForSourceUpdates);
}
[TestMethod]
public void FallbackSnapshotItemCache_ReusesWrappedItemsForStableIdentity()
{
var cache = new FallbackSnapshotItemCache(new MockHost(), CommandProviderContext.Empty, "com.microsoft.cmdpal.indexer", "Indexer");
var firstSource = new MockListItem("Open foo.txt", new NoOpCommand() { Id = "foo.txt" });
var firstItems = cache.Materialize(
[new FallbackSnapshotDefinition(firstSource, true, null, null)],
string.Empty,
hasAlias: false,
new FallbackCommandInvocationArgs { Query = "foo", QueryId = "query-1" },
() => true);
var secondSource = new MockListItem("Open foo.txt (updated)", new NoOpCommand() { Id = "foo.txt" });
var secondItems = cache.Materialize(
[new FallbackSnapshotDefinition(secondSource, true, null, null)],
string.Empty,
hasAlias: false,
new FallbackCommandInvocationArgs { Query = "foo", QueryId = "query-2" },
() => true);
Assert.AreSame(firstItems[0], secondItems[0]);
Assert.AreEqual("Open foo.txt (updated)", secondItems[0].Title);
Assert.AreEqual("query-2", ((IFallbackResultItem)secondItems[0]).InvocationArgs?.QueryId);
}
[TestMethod]
public void FallbackSnapshotItemCache_ClearDetachesSourceItemUpdates()
{
var cache = new FallbackSnapshotItemCache(new MockHost(), CommandProviderContext.Empty, "com.microsoft.cmdpal.indexer", "Indexer");
var source = new MockListItem("Open bar.txt", new NoOpCommand() { Id = "bar.txt" })
{
Icon = new IconInfo("\uE8A5"),
};
var wrapped = (FallbackQueryResultItem)cache.Materialize(
[new FallbackSnapshotDefinition(source, true, null, null)],
string.Empty,
hasAlias: false,
new FallbackCommandInvocationArgs { Query = "bar", QueryId = "query-3" },
() => true)[0];
var oldIcon = wrapped.Icon;
cache.Clear();
source.Icon = new IconInfo("\uE7C3");
Assert.AreSame(oldIcon, wrapped.Icon);
}
private sealed partial class MockListItem : ListItem
{
public MockListItem(string title, ICommand? command = null)
: base(command ?? new NoOpCommand())
{
Title = title;
}
}
private sealed partial class MockFallbackListItem : ListItem, IFallbackResultItem
{
public MockFallbackListItem(
string title,
string extensionName,
string fallbackSourceId,
IFallbackCommandInvocationArgs invocationArgs,
ICommand? command = null)
: base(command ?? new NoOpCommand() { Id = fallbackSourceId })
{
Title = title;
ExtensionName = extensionName;
FallbackSourceId = fallbackSourceId;
InvocationArgs = invocationArgs;
}
public string FallbackSourceId { get; }
public string ExtensionName { get; }
public bool HasAlias => false;
public string AliasText => string.Empty;
public AppExtensionHost ExtensionHost { get; } = new MockHost();
public ICommandProviderContext ProviderContext { get; } = CommandProviderContext.Empty;
public IFallbackCommandInvocationArgs? InvocationArgs { get; }
public bool IsCurrent => true;
}
private sealed partial class CountingFallbackListItem : ListItem, IFallbackCommandItem, IFallbackCommandItem2, IFallbackHandler
{
public CountingFallbackListItem()
: base(new NoOpCommand() { Id = "com.microsoft.cmdpal.test.fallback" })
{
}
public int UpdateQueryCallCount { get; private set; }
public IFallbackHandler? FallbackHandler => this;
public string DisplayTitle => "Fallback";
public string Id => "com.microsoft.cmdpal.test.fallback";
public void UpdateQuery(string query)
{
UpdateQueryCallCount++;
Title = $"Result {query}";
}
}
private sealed partial class DefaultsFallbackListItem : FallbackCommandItem
{
public DefaultsFallbackListItem(string id)
: base("Fallback", id)
{
}
}
private sealed partial class MockHost : AppExtensionHost
{
public override string? GetExtensionDisplayName() => "Mock Host";
}
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class FallbackRenderBlockFactoryTests
{
private static readonly PrecomputedFuzzyMatcher Matcher = new(new PrecomputedFuzzyMatcherOptions());
private static readonly string[] ExpectedFileSectionItems = ["Files", "file-1.txt", "file-2.txt"];
private static readonly string[] ExpectedLeadingFileSectionItems = ["Files", "file-0.txt"];
private static readonly int[] ExpectedRankedScores = [7, 7, 7];
[TestMethod]
public void Create_GlobalInline_ReturnsOnlyScoredGlobalItems()
{
var query = Matcher.PrecomputeQuery("file");
IListItem[] items =
[
new MockListItem("file-1.txt"),
new MockListItem("other"),
new MockListItem(string.Empty),
];
var block = FallbackRenderBlockFactory.Create(
items,
treatAsGlobal: true,
score: 0,
showResultsInDedicatedSection: false,
showBeforeMainResults: false,
sectionSeparator: null,
query,
ScoreByContains);
Assert.AreEqual(0, block.LeadingItems.Length);
Assert.AreEqual(1, block.ScoredGlobalItems.Length);
Assert.AreEqual("file-1.txt", block.ScoredGlobalItems[0].Item.Title);
Assert.AreEqual(0, block.TrailingGlobalItems.Length);
Assert.AreEqual(0, block.OrderedFallbackItems.Length);
}
[TestMethod]
public void Create_GlobalDedicatedSection_ReturnsTrailingSectionItems()
{
var query = Matcher.PrecomputeQuery("file");
IListItem[] items =
[
new MockListItem("file-1.txt"),
new MockListItem("file-2.txt"),
];
var block = FallbackRenderBlockFactory.Create(
items,
treatAsGlobal: true,
score: 0,
showResultsInDedicatedSection: true,
showBeforeMainResults: false,
sectionSeparator: new Separator("Files"),
query,
ScoreByContains);
CollectionAssert.AreEqual(
ExpectedFileSectionItems,
block.TrailingGlobalItems.Select(item => item.Title).ToArray());
}
[TestMethod]
public void Create_LeadingSection_ReturnsLeadingItems()
{
var query = Matcher.PrecomputeQuery("file");
IListItem[] items =
[
new MockListItem("file-0.txt"),
];
var block = FallbackRenderBlockFactory.Create(
items,
treatAsGlobal: true,
score: 0,
showResultsInDedicatedSection: true,
showBeforeMainResults: true,
sectionSeparator: new Separator("Files"),
query,
ScoreByContains);
CollectionAssert.AreEqual(
ExpectedLeadingFileSectionItems,
block.LeadingItems.Select(item => item.Title).ToArray());
}
[TestMethod]
public void Create_RankedDedicatedSection_ReturnsOrderedItems()
{
var query = Matcher.PrecomputeQuery("file");
IListItem[] items =
[
new MockListItem("file-1.txt"),
new MockListItem("file-2.txt"),
];
var block = FallbackRenderBlockFactory.Create(
items,
treatAsGlobal: false,
score: 7,
showResultsInDedicatedSection: true,
showBeforeMainResults: false,
sectionSeparator: new Separator("Files"),
query,
ScoreByContains);
CollectionAssert.AreEqual(
ExpectedFileSectionItems,
block.OrderedFallbackItems.Select(item => item.Item.Title).ToArray());
CollectionAssert.AreEqual(
ExpectedRankedScores,
block.OrderedFallbackItems.Select(item => item.Score).ToArray());
}
private static int ScoreByContains(in FuzzyQuery query, IListItem item)
{
return item.Title.Contains(query.Original, StringComparison.OrdinalIgnoreCase)
? item.Title.Length
: 0;
}
private sealed partial class MockListItem : ListItem
{
public MockListItem(string title)
: base(new NoOpCommand())
{
Title = title;
}
}
}

View File

@@ -19,6 +19,9 @@ public partial class MainListPageResultFactoryTests
{
private static readonly Separator _resultsSeparator = new("Results");
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
private static readonly string[] _expectedLeadingFallbackTitles = ["Files", "file-0.txt", "Results", "F1"];
private static readonly string[] _expectedSectionedGlobalFallbackTitles = ["Results", "F1", "Files", "file-1.txt", "file-2.txt"];
private static readonly string[] _expectedSectionedFallbackTitles = ["Fallbacks", "FB1", "Files", "file-1.txt", "file-2.txt"];
private sealed partial class MockListItem : IListItem
{
@@ -246,4 +249,90 @@ public partial class MainListPageResultFactoryTests
Assert.IsNotNull(result);
Assert.AreEqual(0, result.Length);
}
[TestMethod]
public void Merge_AppendsSectionedGlobalFallbackItemsAfterMergedResults()
{
var filtered = new List<RoScored<IListItem>>
{
S("F1", 100),
};
IListItem[] sectionedGlobalFallbacks =
[
new Separator("Files"),
new MockListItem { Title = "file-1.txt" },
new MockListItem { Title = "file-2.txt" },
];
var result = MainListPageResultFactory.Create(
filtered,
null,
null,
sectionedGlobalFallbacks,
null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
CollectionAssert.AreEqual(
_expectedSectionedGlobalFallbackTitles,
result.Select(item => item.Title).ToArray());
}
[TestMethod]
public void Merge_PrependsLeadingFallbackItemsBeforeResults()
{
var filtered = new List<RoScored<IListItem>>
{
S("F1", 100),
};
IListItem[] leadingFallbackItems =
[
new Separator("Files"),
new MockListItem { Title = "file-0.txt" },
];
var result = MainListPageResultFactory.Create(
filtered,
null,
null,
leadingFallbackItems,
null,
null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
CollectionAssert.AreEqual(
_expectedLeadingFallbackTitles,
result.Select(item => item.Title).ToArray());
}
[TestMethod]
public void Merge_PreservesSectionedFallbackItemsInsideFallbackArea()
{
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
new(new Separator("Files"), 0),
S("file-1.txt", 0),
S("file-2.txt", 0),
};
var result = MainListPageResultFactory.Create(
null,
null,
null,
null,
fallbacks,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
CollectionAssert.AreEqual(
_expectedSectionedFallbackTitles,
result.Select(item => item.Title).ToArray());
}
}

View File

@@ -0,0 +1,202 @@
---
author: OpenAI Codex
created on: 2026-03-09
last updated: 2026-03-09
issue id: n/a
---
# Instance Identity for SDK Objects
## Status
Draft proposal.
## Summary
Command Palette currently often treats object reference equality as identity.
That is not reliable for dynamic lists, fallback results, or any provider that
recreates objects while still referring to the same logical item.
Across COM / WinRT boundaries, the host often sees proxies or rematerialized
wrappers instead of the original object instance. That means default equality is
too weak to drive reuse in hot paths.
The result is avoidable work:
- unnecessary wrapper and view model recreation
- extra list churn during refresh
- repeated icon/detail rebinding
- more UI notifications than needed
- visible flicker and reduced perceived responsiveness while typing
This proposal is also intentionally low-risk from a compatibility perspective:
- the contract is optional
- existing extensions continue to work unchanged
- the host can keep using synthesized identity when explicit instance identity
is missing
- extensions can opt in gradually where the performance benefit is worth it
This proposal adds an optional SDK contract:
```csharp
interface IObjectWithInstanceIdentity
{
String InstanceIdentity { get; };
}
```
The purpose is to let the host recognize when two different object instances
represent the same logical item.
## Problem
Default object equality is not enough for SDK objects crossing COM / WinRT
boundaries.
In practice, the host often receives proxies or freshly materialized wrappers,
not the original in-proc object instance. Two host-side objects may represent
the same remote logical item while still failing reference equality. Likewise, a
provider may recreate an item and return a new proxy for the same logical
result.
Without explicit instance identity:
- list refreshes can recreate view models unnecessarily
- fallback results can look like full replacements instead of updates
- icon/detail updates can cause avoidable churn and flicker
- selection and focus are harder to preserve
- the host needs heuristics to guess whether two objects are "the same"
This is mostly a performance and responsiveness problem.
## Proposal
Add an optional interface:
```csharp
interface IObjectWithInstanceIdentity
{
String InstanceIdentity { get; };
}
```
Toolkit base classes may implement this with a default empty value so
extensions can opt in without extra plumbing.
The host should prefer explicit instance identity when available and only fall
back to synthesized keys when it is missing.
## Semantics
`InstanceIdentity` means:
> "This object represents the same logical item as another object with the same
> instance identity, within the same owning source."
It should be:
- stable while the logical item remains the same
- unique among sibling items from the same source
- cheap to compute
- independent from query text, rank, or current visual state
It is not:
- a command id
- a provider id
- a fallback `QueryId`
- a security boundary
- a globally unique system identifier
## Host Behavior
The host should treat instance identity as source-scoped, never global.
Examples:
- provider id + instance identity
- fallback source id + instance identity
- page id + instance identity
Collisions between different providers or extensions are therefore acceptable as
long as the owning source is different. Two different providers may both return
`InstanceIdentity = "settings"` or `InstanceIdentity = "readme"`, and the host
should still treat those as different logical items because their source scope
is different.
Within a single source, duplicate instance identities should be treated as a
provider bug. The host should tolerate that safely, for example by logging the
collision and falling back to a disambiguated synthetic key for that refresh,
rather than assuming the items are interchangeable.
If two items from the same source have the same `InstanceIdentity`, the host
should prefer to treat them as the same logical item for:
- wrapper reuse
- view model reuse
- preserving selection/focus
- in-place icon/detail updates
- minimizing UI churn
If the item does not implement `IObjectWithInstanceIdentity`, the host may
synthesize a best-effort key from other stable properties. That should remain a
compatibility fallback, not the preferred path.
## Extension Guidance
Extensions should provide `InstanceIdentity` whenever the host may see multiple
instances of the same logical item across time.
Good candidates:
- dynamic list items
- fallback results
- search results
- saved entities
- items with asynchronous property updates
Good examples:
```csharp
item.InstanceIdentity = canonicalPath;
item.InstanceIdentity = savedConnection.Id;
item.InstanceIdentity = "search-web";
```
Bad examples:
```csharp
item.InstanceIdentity = query;
item.InstanceIdentity = $"{query}:{DateTime.Now.Ticks}";
item.InstanceIdentity = $"{title}:{rank}";
```
The same logical item should keep the same instance identity even if its title,
subtitle, icon, rank, or current query changes.
## Compatibility
This proposal is additive.
- existing extensions continue to work unchanged
- the host can keep synthesizing fallback identities when needed
- extensions can opt in gradually
## Open Questions
- Should more SDK types expose instance identity explicitly?
- Should host-side list/view-model caches eventually be identity-aware by
default?
- Should some result types strongly recommend instance identity, even if it
remains optional?
## Recommendation
Add `IObjectWithInstanceIdentity` as an optional SDK contract.
Extensions should use it for dynamic or frequently recreated items. The host
should use it as the preferred basis for reuse and incremental updates.
This is a small API addition with meaningful impact on performance, visual
stability, and maintainability.

View File

@@ -0,0 +1,94 @@
---
author: <author>
created on: YYYY-MM-DD
last updated: YYYY-MM-DD
issue id: <issue or n/a>
---
# <Proposal Title>
## Status
Draft proposal.
## Summary
Describe the proposal in 2-5 short paragraphs:
- what problem this solves
- what API or behavior is being proposed
- why it is worth doing
## Problem
Explain the current problem and why existing behavior is not sufficient.
Cover:
- user-visible impact
- host / extension impact
- performance or correctness impact
- why existing ids, equality, or contracts are insufficient if relevant
## Proposal
Describe the proposed contract or behavior.
Include:
- API shape
- host behavior
- extension behavior
- any important naming or versioning notes
## Semantics
Define precisely what the proposal means.
Call out:
- what is guaranteed
- what is not guaranteed
- what inputs or states it should and should not depend on
## Host Behavior
Describe how the host should interpret and use the proposal.
Examples:
- caching
- reuse
- rendering
- invocation
- ordering
- stale-result handling
## Extension Guidance
Describe how extension authors should use the feature.
Include:
- when to use it
- when not to use it
- good examples
- bad examples
## Compatibility
Explain compatibility and rollout expectations.
Examples:
- additive or breaking
- fallback behavior for old extensions
- migration notes
## Open Questions
List remaining design questions or deferred decisions.
## Recommendation
State the recommended direction in a few sentences.

View File

@@ -0,0 +1,215 @@
// Copyright (c) Microsoft Corporation
// 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.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Ext.Indexer;
internal sealed partial class FallbackOpenFilesItem : FallbackCommandItem, IFallbackHandler2
{
private const string CommandId = "com.microsoft.cmdpal.builtin.indexer.fallback";
private const uint HardQueryCookie = 10;
private const int MaxFallbackItems = 5;
private static readonly NoOpCommand BaseCommandWithId = new() { Id = CommandId };
private readonly Lock _queryLock = new();
private readonly Dictionary<string, CancellationTokenSource> _queries = [];
private Func<string, bool>? _suppressCallback;
public FallbackOpenFilesItem()
: base(BaseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, CommandId)
{
Icon = Icons.FileExplorerIcon;
SuggestedQueryDelayMilliseconds = new(true, 120);
SuggestedMinQueryLength = new(true, 2);
}
public override void UpdateQuery(string query)
{
// This fallback is query-snapshot based and is expected to be driven via IFallbackHandler2.
}
public IAsyncOperation<IFallbackCommandResult> UpdateQueryAsync(string query, string queryId)
{
var cts = new CancellationTokenSource();
lock (_queryLock)
{
_queries[queryId] = cts;
}
return Task.Run(() => ExecuteQuery(query, queryId, cts)).AsAsyncOperation();
}
public void CancelQuery(string queryId)
{
CancellationTokenSource? cts = null;
lock (_queryLock)
{
if (_queries.TryGetValue(queryId, out cts))
{
_queries.Remove(queryId);
}
}
if (cts is not null)
{
cts.Cancel();
}
}
public void SuppressFallbackWhen(Func<string, bool> callback)
{
_suppressCallback = callback;
}
private IFallbackCommandResult ExecuteQuery(string query, string queryId, CancellationTokenSource cts)
{
try
{
var token = cts.Token;
token.ThrowIfCancellationRequested();
var suppressCallback = _suppressCallback;
if (string.IsNullOrWhiteSpace(query) || (suppressCallback is not null && suppressCallback(query)))
{
return EmptyResult(query, queryId);
}
if (Path.Exists(query))
{
var directMatch = new IndexerListItem(new IndexerItem(fullPath: query), IncludeBrowseCommand.AsDefault)
{
Icon = Icons.FileExplorerIcon,
};
StartLazyIconLoad(directMatch, query, token);
return CreateResult(query, queryId, directMatch);
}
using var searchEngine = new SearchEngine();
searchEngine.Query(query, queryCookie: HardQueryCookie);
token.ThrowIfCancellationRequested();
var results = searchEngine.FetchItems(0, MaxFallbackItems, queryCookie: HardQueryCookie, out _, noIcons: true);
token.ThrowIfCancellationRequested();
foreach (var result in results)
{
if (result is CommandItem commandItem && commandItem.Icon is null)
{
commandItem.Icon = Icons.FileExplorerIcon;
}
if (result is IndexerListItem indexerListItem)
{
StartLazyIconLoad(indexerListItem, indexerListItem.FilePath, token);
}
}
return CreateResult(query, queryId, results);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return EmptyResult(query, queryId);
}
finally
{
CleanupQuery(queryId, cts);
}
}
private static FallbackCommandResult CreateResult(string query, string queryId, params IListItem[] items)
{
return new FallbackCommandResult
{
Query = query,
QueryId = queryId,
Items = items,
};
}
private static FallbackCommandResult CreateResult(string query, string queryId, IEnumerable<IListItem> items)
{
return new FallbackCommandResult
{
Query = query,
QueryId = queryId,
Items = items.ToArray(),
};
}
private static FallbackCommandResult EmptyResult(string query, string queryId) => CreateResult(query, queryId, []);
private static IIconInfo LoadIconOrDefault(string path)
{
try
{
var stream = ThumbnailHelper.GetThumbnail(path).GetAwaiter().GetResult();
if (stream is not null)
{
var iconData = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
return new IconInfo(iconData, iconData);
}
}
catch
{
}
return Icons.FileExplorerIcon;
}
private static void StartLazyIconLoad(IndexerListItem item, string path, CancellationToken cancellationToken)
{
_ = Task.Run(
() =>
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
var icon = LoadIconOrDefault(path);
if (cancellationToken.IsCancellationRequested)
{
return;
}
item.Icon = icon;
},
cancellationToken);
}
private void CleanupQuery(string queryId, CancellationTokenSource cts)
{
lock (_queryLock)
{
if (_queries.TryGetValue(queryId, out var current) && ReferenceEquals(current, cts))
{
_queries.Remove(queryId);
}
}
cts.Dispose();
}
}

View File

@@ -13,7 +13,7 @@ namespace Microsoft.CmdPal.Ext.Indexer;
public partial class IndexerCommandsProvider : CommandProvider
{
private readonly FallbackOpenFileItem _fallbackFileItem = new();
private readonly FallbackOpenFilesItem _fallbackFileItem = new();
public IndexerCommandsProvider()
{

View File

@@ -241,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
}
/// <summary>
/// Looks up a localized string similar to Find file from path.
/// Looks up a localized string similar to Find files by name.
/// </summary>
internal static string Indexer_Find_Path_fallback_display_title {
get {

View File

@@ -151,7 +151,7 @@
<value>This is a file, not a folder</value>
</data>
<data name="Indexer_Find_Path_fallback_display_title" xml:space="preserve">
<value>Find file from path</value>
<value>Find files by name</value>
</data>
<data name="Indexer_Folder_Is_Empty" xml:space="preserve">
<value>This folder is empty</value>

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -16,12 +16,6 @@ internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.remotedesktop.fallback";
private static readonly UriHostNameType[] ValidUriHostNameTypes = [
UriHostNameType.IPv6,
UriHostNameType.IPv4,
UriHostNameType.Dns
];
private static readonly CompositeFormat RemoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host);
private readonly IRdpConnectionsManager _rdpConnectionsManager;
@@ -32,6 +26,8 @@ internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem
{
_rdpConnectionsManager = rdpConnectionsManager;
SuggestedQueryDelayMilliseconds = new(true, 75);
SuggestedMinQueryLength = new(true, 2);
Command = _emptyCommand;
Title = string.Empty;
Subtitle = string.Empty;
@@ -55,39 +51,15 @@ internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem
if (queryConnection is not null && !string.IsNullOrWhiteSpace(queryConnection.ConnectionName))
{
var connectionName = queryConnection.ConnectionName;
Command = new OpenRemoteDesktopCommand(connectionName);
Title = connectionName;
Subtitle = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName);
}
else
{
// Strip port suffix (e.g. "localhost:3389") before validation,
// since Uri.CheckHostName does not accept host:port strings.
var hostForValidation = query.Trim();
var lastColon = hostForValidation.LastIndexOf(':');
if (lastColon > 0 && lastColon < hostForValidation.Length - 1)
{
var portPart = hostForValidation.Substring(lastColon + 1);
if (ushort.TryParse(portPart, out _))
{
hostForValidation = hostForValidation.Substring(0, lastColon);
}
}
if (ValidUriHostNameTypes.Contains(Uri.CheckHostName(hostForValidation)))
{
var connectionName = query.Trim();
Command = new OpenRemoteDesktopCommand(connectionName);
Title = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName);
Subtitle = Resources.remotedesktop_title;
}
else
{
Title = string.Empty;
Subtitle = string.Empty;
Command = _emptyCommand;
}
Title = string.Empty;
Subtitle = string.Empty;
Command = _emptyCommand;
}
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using Microsoft.CmdPal.Ext.RemoteDesktop.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands;
internal sealed partial class HostMatchedRemoteDesktopItem : FormattedFallbackCommandItem, IHostMatchedFallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.remotedesktop.host.fallback";
private const string _matchPattern = @"(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*|(?:\d{1,3}\.){3}\d{1,3}|\[[0-9A-Fa-f:.%]+\]|[0-9A-Fa-f:]+)(?::\d{1,5})?";
private static readonly CompositeFormat RemoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host);
private readonly OpenRemoteDesktopCommand _command = new(string.Empty);
public HostMatchedRemoteDesktopItem()
: base(
new OpenRemoteDesktopCommand(string.Empty),
Resources.remotedesktop_title,
_id,
titleTemplate: Resources.remotedesktop_open_host.Replace("{0}", "{query}", StringComparison.Ordinal),
subtitleTemplate: Resources.remotedesktop_title)
{
Title = string.Empty;
Subtitle = string.Empty;
Icon = Icons.RDPIcon;
Command = _command;
}
public HostMatchKind MatchKind => HostMatchKind.Regex;
public string MatchValue => _matchPattern;
public override void UpdateQuery(string query)
{
if (!OpenRemoteDesktopCommand.TryGetValidatedHost(query, out var validatedHost) || string.IsNullOrWhiteSpace(validatedHost))
{
Title = string.Empty;
Subtitle = string.Empty;
Command = _command;
return;
}
Title = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, validatedHost);
Subtitle = Resources.remotedesktop_title;
Command = new OpenRemoteDesktopCommand(validatedHost);
}
}

View File

@@ -11,7 +11,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands;
internal sealed partial class OpenRemoteDesktopCommand : BaseObservable, IInvokableCommand
internal sealed partial class OpenRemoteDesktopCommand : BaseObservable, IInvokableCommand2
{
private static readonly CompositeFormat ProcessErrorFormat =
CompositeFormat.Parse(Resources.remotedesktop_log_mstsc_error);
@@ -37,41 +37,57 @@ internal sealed partial class OpenRemoteDesktopCommand : BaseObservable, IInvoka
}
public ICommandResult Invoke(object sender)
=> InvokeCore(_rdpHost);
public ICommandResult InvokeWithArgs(object sender, IFallbackCommandInvocationArgs args)
=> InvokeCore(args.Query);
internal static bool TryGetValidatedHost(string host, out string validatedHost)
{
validatedHost = host.Trim();
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
// Strip port suffix (e.g. "localhost:3389") before validation,
// since Uri.CheckHostName does not accept host:port strings.
var hostForValidation = validatedHost;
var lastColon = validatedHost.LastIndexOf(':');
if (lastColon > 0 && lastColon < validatedHost.Length - 1)
{
var portPart = validatedHost[(lastColon + 1)..];
if (ushort.TryParse(portPart, out _))
{
hostForValidation = validatedHost[..lastColon];
}
}
return Uri.CheckHostName(hostForValidation) != UriHostNameType.Unknown;
}
private ICommandResult InvokeCore(string host)
{
using var process = new Process();
process.StartInfo.UseShellExecute = false;
process.StartInfo.WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
process.StartInfo.FileName = "mstsc";
if (!string.IsNullOrWhiteSpace(_rdpHost))
if (!TryGetValidatedHost(host, out var validatedHost))
{
// validate that _rdpHost is a proper hostname or IP address
// Strip port suffix (e.g. "localhost:3389") before validation,
// since Uri.CheckHostName does not accept host:port strings.
var hostForValidation = _rdpHost;
var lastColon = _rdpHost.LastIndexOf(':');
if (lastColon > 0 && lastColon < _rdpHost.Length - 1)
return CommandResult.ShowToast(new ToastArgs()
{
var portPart = _rdpHost.Substring(lastColon + 1);
if (ushort.TryParse(portPart, out _))
{
hostForValidation = _rdpHost.Substring(0, lastColon);
}
}
Message = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
InvalidHostnameFormat,
host),
Result = CommandResult.KeepOpen(),
});
}
if (Uri.CheckHostName(hostForValidation) == UriHostNameType.Unknown)
{
return CommandResult.ShowToast(new ToastArgs()
{
Message = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
InvalidHostnameFormat,
_rdpHost),
Result = CommandResult.KeepOpen(),
});
}
process.StartInfo.Arguments = $"/v:{_rdpHost}";
if (!string.IsNullOrWhiteSpace(validatedHost))
{
process.StartInfo.Arguments = $"/v:{validatedHost}";
}
try

View File

@@ -16,6 +16,7 @@ public partial class RemoteDesktopCommandProvider : CommandProvider
{
private readonly CommandItem listPageCommand;
private readonly FallbackRemoteDesktopItem fallback;
private readonly HostMatchedRemoteDesktopItem hostMatchedFallback;
public RemoteDesktopCommandProvider()
{
@@ -28,6 +29,7 @@ public partial class RemoteDesktopCommandProvider : CommandProvider
var listPage = new RemoteDesktopListPage(rdpConnectionsManager);
fallback = new FallbackRemoteDesktopItem(rdpConnectionsManager);
hostMatchedFallback = new HostMatchedRemoteDesktopItem();
listPageCommand = new CommandItem(listPage)
{
@@ -40,5 +42,5 @@ public partial class RemoteDesktopCommandProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() => [listPageCommand];
public override IFallbackCommandItem[] FallbackCommands() => [fallback];
public override IFallbackCommandItem[] FallbackCommands() => [fallback, hostMatchedFallback];
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -24,6 +24,8 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
ResourceLoaderInstance.GetString("shell_command_display_title"),
_id)
{
SuggestedQueryDelayMilliseconds = new(true, 100);
SuggestedMinQueryLength = new(true, 2);
Title = string.Empty;
Subtitle = ResourceLoaderInstance.GetString("generic_run_command");
Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon.

View File

@@ -2,12 +2,14 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch.Commands;
internal sealed partial class OpenURLCommand : InvokableCommand
internal sealed partial class OpenURLCommand : InvokableCommand2
{
private readonly IBrowserInfoService _browserInfoService;
@@ -22,8 +24,38 @@ internal sealed partial class OpenURLCommand : InvokableCommand
}
public override CommandResult Invoke()
=> OpenUrl(Url);
public override ICommandResult InvokeWithArgs(object? sender, IFallbackCommandInvocationArgs args)
=> TryNormalizeUrl(args.Query, out var normalizedUrl) ? OpenUrl(normalizedUrl) : CommandResult.KeepOpen();
internal static bool TryNormalizeUrl(string query, out string normalizedUrl)
{
normalizedUrl = string.Empty;
if (string.IsNullOrWhiteSpace(query))
{
return false;
}
if (Uri.IsWellFormedUriString(query, UriKind.Absolute))
{
normalizedUrl = query;
return true;
}
if (query.Contains('.', StringComparison.OrdinalIgnoreCase) &&
Uri.IsWellFormedUriString("https://" + query, UriKind.Absolute))
{
normalizedUrl = "https://" + query;
return true;
}
return false;
}
private CommandResult OpenUrl(string url)
{
// TODO GH# 138 --> actually display feedback from the extension somewhere.
return _browserInfoService.Open(Url) ? CommandResult.Dismiss() : CommandResult.KeepOpen();
return _browserInfoService.Open(url) ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
}

View File

@@ -6,11 +6,12 @@ using System;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch.Commands;
internal sealed partial class SearchWebCommand : InvokableCommand
internal sealed partial class SearchWebCommand : InvokableCommand2
{
private readonly ISettingsInterface _settingsManager;
private readonly IBrowserInfoService _browserInfoService;
@@ -27,8 +28,14 @@ internal sealed partial class SearchWebCommand : InvokableCommand
}
public override CommandResult Invoke()
=> InvokeSearch(Arguments);
public override ICommandResult InvokeWithArgs(object? sender, IFallbackCommandInvocationArgs args)
=> InvokeSearch(args.Query);
private CommandResult InvokeSearch(string arguments)
{
var uri = BuildUri();
var uri = BuildUri(arguments);
if (!_browserInfoService.Open(uri))
{
@@ -39,17 +46,17 @@ internal sealed partial class SearchWebCommand : InvokableCommand
// remember only the query, not the full URI
if (_settingsManager.HistoryItemCount != 0)
{
_settingsManager.AddHistoryItem(new HistoryItem(Arguments, DateTime.Now));
_settingsManager.AddHistoryItem(new HistoryItem(arguments, DateTime.Now));
}
return CommandResult.Dismiss();
}
private string BuildUri()
private string BuildUri(string arguments)
{
if (string.IsNullOrWhiteSpace(_settingsManager.CustomSearchUri))
{
return $"? " + Arguments;
return $"? " + arguments;
}
// if the custom search URI contains query placeholder, replace it with the actual query
@@ -60,12 +67,12 @@ internal sealed partial class SearchWebCommand : InvokableCommand
{
if (_settingsManager.CustomSearchUri.Contains(placeholder, StringComparison.OrdinalIgnoreCase))
{
return _settingsManager.CustomSearchUri.Replace(placeholder, Uri.EscapeDataString(Arguments), StringComparison.OrdinalIgnoreCase);
return _settingsManager.CustomSearchUri.Replace(placeholder, Uri.EscapeDataString(arguments), StringComparison.OrdinalIgnoreCase);
}
}
// is this too smart?
var separator = _settingsManager.CustomSearchUri.Contains('?') ? '&' : '?';
return $"{_settingsManager.CustomSearchUri}{separator}q={Uri.EscapeDataString(Arguments)}";
return $"{_settingsManager.CustomSearchUri}{separator}q={Uri.EscapeDataString(arguments)}";
}
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
@@ -11,7 +12,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch.Commands;
internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem
internal sealed partial class FallbackExecuteSearchItem : FormattedFallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.websearch.execute.fallback";
private readonly SearchWebCommand _executeItem;
@@ -21,7 +22,12 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem
private readonly IBrowserInfoService _browserInfoService;
public FallbackExecuteSearchItem(ISettingsInterface settings, IBrowserInfoService browserInfoService)
: base(new SearchWebCommand(string.Empty, settings, browserInfoService) { Id = _id }, Resources.command_item_title, _id)
: base(
new SearchWebCommand(string.Empty, settings, browserInfoService) { Id = _id },
Resources.command_item_title,
_id,
titleTemplate: string.Empty,
subtitleTemplate: string.Empty)
{
_executeItem = (SearchWebCommand)Command!;
_browserInfoService = browserInfoService;
@@ -31,6 +37,10 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem
Icon = Icons.WebSearch;
}
public override string TitleTemplate => UpdateBrowserName(_browserInfoService);
public override string SubtitleTemplate => Resources.web_search_fallback_subtitle.Replace("{0}", "{query}", StringComparison.Ordinal);
private static string UpdateBrowserName(IBrowserInfoService browserInfoService)
{
var browserName = browserInfoService.GetDefaultBrowser()?.Name;

View File

@@ -9,20 +9,28 @@ using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch;
internal sealed partial class FallbackOpenURLItem : FallbackCommandItem
internal sealed partial class FallbackOpenURLItem : FormattedFallbackCommandItem, IHostMatchedFallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.websearch.openurl.fallback";
private const string FallbackId = "com.microsoft.cmdpal.builtin.websearch.openurl.fallback";
private const string MatchPattern = @"(?:(?:https?|ftp|file)://)?[^\s]+\.[^\s]+";
private readonly IBrowserInfoService _browserInfoService;
private readonly OpenURLCommand _executeItem;
private static readonly CompositeFormat PluginOpenURL = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url);
private static readonly CompositeFormat PluginOpenUrlInBrowser = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url_in_browser);
public FallbackOpenURLItem(ISettingsInterface settings, IBrowserInfoService browserInfoService)
: base(new OpenURLCommand(string.Empty, browserInfoService), Resources.open_url_fallback_title, _id)
: base(
new OpenURLCommand(string.Empty, browserInfoService),
Resources.open_url_fallback_title,
FallbackId,
titleTemplate: string.Empty,
subtitleTemplate: string.Empty)
{
ArgumentNullException.ThrowIfNull(browserInfoService);
@@ -34,9 +42,17 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem
Icon = Icons.WebSearch;
}
public override string TitleTemplate => Resources.plugin_open_url.Replace("{0}", "{query}", StringComparison.Ordinal);
public override string SubtitleTemplate => GetSubtitle();
public HostMatchKind MatchKind => HostMatchKind.Regex;
public string MatchValue => MatchPattern;
public override void UpdateQuery(string query)
{
if (!IsValidUrl(query))
if (!OpenURLCommand.TryNormalizeUrl(query, out var normalizedUrl))
{
_executeItem.Url = string.Empty;
_executeItem.Name = string.Empty;
@@ -45,53 +61,18 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem
return;
}
var success = Uri.TryCreate(query, UriKind.Absolute, out _);
_executeItem.Url = normalizedUrl;
_executeItem.Name = string.IsNullOrEmpty(normalizedUrl) ? string.Empty : Resources.open_in_default_browser;
// if url not contain schema, add http:// by default.
if (!success)
{
query = "https://" + query;
}
_executeItem.Url = query;
_executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Resources.open_in_default_browser;
Title = string.Format(CultureInfo.CurrentCulture, PluginOpenURL, query);
var browserName = _browserInfoService.GetDefaultBrowser()?.Name;
Subtitle = string.IsNullOrWhiteSpace(browserName) ? Resources.open_in_default_browser : string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, browserName);
Title = string.Format(CultureInfo.CurrentCulture, PluginOpenURL, normalizedUrl);
Subtitle = GetSubtitle();
}
private static bool IsValidUrl(string url)
private string GetSubtitle()
{
if (string.IsNullOrWhiteSpace(url))
{
return false;
}
if (!url.Contains('.', StringComparison.OrdinalIgnoreCase))
{
// eg: 'com', 'org'. We don't think it's a valid url.
// This can simplify the logic of checking if the url is valid.
return false;
}
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
return true;
}
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.IsWellFormedUriString("https://" + url, UriKind.Absolute))
{
return true;
}
}
return false;
var browserName = _browserInfoService.GetDefaultBrowser()?.Name;
return string.IsNullOrWhiteSpace(browserName)
? Resources.open_in_default_browser
: string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, browserName);
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -8,7 +8,7 @@ using WinRT;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class CommandItem : BaseObservable, ICommandItem
public partial class CommandItem : BaseObservable, ICommandItem, IObjectWithIdentity
{
// NOTE TO MAINTAINERS: Do NOT implement `IExtendedAttributesProvider` here
// directly. Instead, implement it in derived classes like `ListItem` where
@@ -43,6 +43,8 @@ public partial class CommandItem : BaseObservable, ICommandItem
public virtual string Subtitle { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Identity { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual ICommand? Command
{
get => _command;
@@ -128,6 +130,7 @@ public partial class CommandItem : BaseObservable, ICommandItem
{
Command = other.Command;
Subtitle = other.Subtitle;
Identity = other is IObjectWithIdentity identifiable ? identifiable.Identity : string.Empty;
Icon = (IconInfo?)other.Icon;
MoreCommands = other.MoreCommands;
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FallbackCommandInvocationArgs : IFallbackCommandInvocationArgs
{
public required string Query { get; init; }
public required string QueryId { get; init; }
public FallbackCommandInvocationArgs()
{
}
public FallbackCommandInvocationArgs(string query, string queryId)
{
Query = query;
QueryId = queryId;
}
}

View File

@@ -4,7 +4,7 @@
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IFallbackHandler, IFallbackCommandItem2, IExtendedAttributesProvider
public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IFallbackHandler, IFallbackCommandItem2, IFallbackCommandItemDefaults, IExtendedAttributesProvider
{
private readonly IFallbackHandler? _fallbackHandler;
@@ -40,5 +40,9 @@ public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IF
public virtual string DisplayTitle { get; }
public virtual OptionalUInt32 SuggestedQueryDelayMilliseconds { get; set; }
public virtual OptionalUInt32 SuggestedMinQueryLength { get; set; }
public virtual void UpdateQuery(string query) => _fallbackHandler?.UpdateQuery(query);
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FallbackCommandResult : IFallbackCommandResult
{
public required string Query { get; set; }
public required string QueryId { get; set; }
public IListItem[] Items { get; set; } = [];
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FormattedFallbackCommandItem : FallbackCommandItem, IFormattedFallbackCommandItem
{
public FormattedFallbackCommandItem(
ICommand command,
string displayTitle,
string id,
string titleTemplate,
string subtitleTemplate = "")
: base(command, displayTitle, id)
{
TitleTemplate = titleTemplate;
SubtitleTemplate = subtitleTemplate;
}
public virtual string TitleTemplate { get; }
public virtual string SubtitleTemplate { get; }
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public abstract partial class InvokableCommand2 : InvokableCommand, IInvokableCommand2
{
public virtual ICommandResult InvokeWithArgs(object? sender, IFallbackCommandInvocationArgs args)
{
return Invoke(sender);
}
}

View File

@@ -60,7 +60,7 @@ namespace Microsoft.CommandPalette.Extensions
String Id{ get; };
IIconInfo Icon{ get; };
}
enum CommandResultKind {
Dismiss, // Reset the palette to the main page and dismiss
GoHome, // Go back to the main page, but keep it open
@@ -71,13 +71,13 @@ namespace Microsoft.CommandPalette.Extensions
ShowToast, // Display a transient message to the user
Confirm, // Display a confirmation dialog
};
enum NavigationMode {
Push, // Push the target page onto the navigation stack
GoBack, // Go back one page before navigating to the target page
GoHome, // Go back to the home page before navigating to the target page
};
[uuid("f9d6423b-bd5e-44bb-a204-2f5c77a72396")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandResultArgs{};
@@ -103,7 +103,7 @@ namespace Microsoft.CommandPalette.Extensions
ICommand PrimaryCommand { get; };
Boolean IsPrimaryCommandCritical { get; };
}
// This is a "leaf" of the UI. This is something that can be "done" by the user.
// * A ListPage
// * the MoreCommands flyout of for a ListItem or a MarkdownPage
@@ -111,29 +111,42 @@ namespace Microsoft.CommandPalette.Extensions
interface IInvokableCommand requires ICommand {
ICommandResult Invoke(Object sender);
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackCommandInvocationArgs
{
String Query { get; };
String QueryId { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IInvokableCommand2 requires IInvokableCommand
{
ICommandResult InvokeWithArgs(Object sender, IFallbackCommandInvocationArgs args);
}
[uuid("ef5db50c-d26b-4aee-9343-9f98739ab411")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFilterItem {}
[uuid("0a923c7f-5b7b-431d-9898-3c8c841d02ed")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ISeparatorFilterItem requires IFilterItem {}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFilter requires INotifyPropChanged, IFilterItem {
String Id { get; };
String Name { get; };
IIconInfo Icon { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFilters {
String CurrentFilterId { get; set; };
IFilterItem[] GetFilters();
}
struct Color
{
UInt8 R;
@@ -141,13 +154,19 @@ namespace Microsoft.CommandPalette.Extensions
UInt8 B;
UInt8 A;
};
struct OptionalColor
{
Boolean HasValue;
Microsoft.CommandPalette.Extensions.Color Color;
};
struct OptionalUInt32
{
Boolean HasValue;
UInt32 Value;
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ITag {
IIconInfo Icon { get; };
@@ -156,7 +175,7 @@ namespace Microsoft.CommandPalette.Extensions
OptionalColor Background { get; };
String ToolTip { get; };
};
[uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IDetailsData {}
@@ -197,7 +216,7 @@ namespace Microsoft.CommandPalette.Extensions
[uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IDetailsSeparator requires IDetailsData {}
enum MessageState
{
Info = 0,
@@ -205,20 +224,20 @@ namespace Microsoft.CommandPalette.Extensions
Warning,
Error,
};
enum StatusContext
{
Page,
Extension
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IProgressState requires INotifyPropChanged
{
Boolean IsIndeterminate { get; };
UInt32 ProgressPercent { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IStatusMessage requires INotifyPropChanged
{
@@ -227,35 +246,35 @@ namespace Microsoft.CommandPalette.Extensions
String Message { get; };
// TODO! Icon maybe? Work with design on this
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ILogMessage
{
MessageState State { get; };
String Message { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IExtensionHost
{
Windows.Foundation.IAsyncAction ShowStatus(IStatusMessage message, StatusContext context);
Windows.Foundation.IAsyncAction HideStatus(IStatusMessage message);
Windows.Foundation.IAsyncAction LogMessage(ILogMessage message);
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IPage requires ICommand {
String Title { get; };
Boolean IsLoading { get; };
OptionalColor AccentColor { get; };
}
[uuid("c78b9851-e76b-43ee-8f76-da5ba14e69a4")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IContextItem {}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandItem requires INotifyPropChanged {
ICommand Command{ get; };
@@ -264,17 +283,23 @@ namespace Microsoft.CommandPalette.Extensions
String Title{ get; };
String Subtitle{ get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IObjectWithIdentity
{
String Identity { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandContextItem requires ICommandItem, IContextItem {
Boolean IsCritical { get; }; // READ: "make this red"
KeyChord RequestedShortcut { get; };
}
[uuid("924a87fc-32fe-4471-9156-84b3b30275a6")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ISeparatorContextItem requires IContextItem {}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IListItem requires ICommandItem {
ITag[] Tags{ get; };
@@ -290,20 +315,20 @@ namespace Microsoft.CommandPalette.Extensions
[uuid("05914D59-6ECB-4992-9CF2-5982B5120A26")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ISmallGridLayout requires IGridProperties { }
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IMediumGridLayout requires IGridProperties
{
Boolean ShowTitle { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IGalleryGridLayout requires IGridProperties
{
Boolean ShowTitle { get; };
Boolean ShowSubtitle { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IListPage requires IPage, INotifyItemsChanged {
// DevPal will be responsible for filtering the list of items, unless the
@@ -315,21 +340,21 @@ namespace Microsoft.CommandPalette.Extensions
IGridProperties GridProperties { get; };
Boolean HasMoreItems { get; };
ICommandItem EmptyContent { get; };
IListItem[] GetItems();
void LoadMore();
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IDynamicListPage requires IListPage {
String SearchText { set; };
}
[uuid("b64def0f-8911-4afa-8f8f-042bd778d088")]
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IContent requires INotifyPropChanged {
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFormContent requires IContent {
String TemplateJson { get; };
@@ -337,35 +362,50 @@ namespace Microsoft.CommandPalette.Extensions
String StateJson { get; };
ICommandResult SubmitForm(String inputs, String data);
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IMarkdownContent requires IContent {
String Body { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ITreeContent requires IContent, INotifyItemsChanged {
IContent RootContent { get; };
IContent[] GetChildren();
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IContentPage requires IPage, INotifyItemsChanged {
IContent[] GetContent();
IDetails Details { get; };
IContextItem[] Commands { get; };
}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandSettings {
IContentPage SettingsPage { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackHandler {
void UpdateQuery(String query);
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackCommandResult
{
String Query { get; };
String QueryId { get; };
IListItem[] Items { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackHandler2 requires IFallbackHandler
{
Windows.Foundation.IAsyncOperation<IFallbackCommandResult> UpdateQueryAsync(String query, String queryId);
void CancelQuery(String queryId);
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackCommandItem requires ICommandItem {
IFallbackHandler FallbackHandler{ get; };
@@ -376,7 +416,35 @@ namespace Microsoft.CommandPalette.Extensions
interface IFallbackCommandItem2 requires IFallbackCommandItem {
String Id { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFormattedFallbackCommandItem requires IFallbackCommandItem2
{
String TitleTemplate { get; };
String SubtitleTemplate { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
enum HostMatchKind
{
None = 0,
Regex = 1,
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IHostMatchedFallbackCommandItem requires IFormattedFallbackCommandItem
{
HostMatchKind MatchKind { get; };
String MatchValue { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFallbackCommandItemDefaults requires IFallbackCommandItem
{
OptionalUInt32 SuggestedQueryDelayMilliseconds { get; };
OptionalUInt32 SuggestedMinQueryLength { get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandProvider requires Windows.Foundation.IClosable, INotifyItemsChanged
{
@@ -385,21 +453,21 @@ namespace Microsoft.CommandPalette.Extensions
IIconInfo Icon { get; };
ICommandSettings Settings { get; };
Boolean Frozen { get; };
ICommandItem[] TopLevelCommands();
IFallbackCommandItem[] FallbackCommands();
ICommand GetCommand(String id);
void InitializeWithHost(IExtensionHost host);
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IExtendedAttributesProvider
{
Windows.Foundation.Collections.IMap<String, Object> GetProperties();
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandProvider2 requires ICommandProvider
{