mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-04 01:20:02 +02:00
Compare commits
1 Commits
dependabot
...
dev/jpolas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80d09509d |
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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([], [], [], []);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
202
src/modules/cmdpal/doc/feature-specs/object-identity.md
Normal file
202
src/modules/cmdpal/doc/feature-specs/object-identity.md
Normal 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.
|
||||
94
src/modules/cmdpal/doc/feature-specs/proposal-template.md
Normal file
94
src/modules/cmdpal/doc/feature-specs/proposal-template.md
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user