mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 01:36:31 +02:00
This PR does fix a bug where an item that starts with a null or empty primary command never adds that primary action to the context menu after the extension later provides a real command. - Creates the default primary context-menu item lazily when `Command` or `Command.Name` becomes available after `SlowInitializeProperties()` - Refreshes `AllCommands`, `SecondaryCommand`, and `HasMoreCommands` notifications for late command materialization and Show Details updates. - Adds unit tests to cover the fixed issue. <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #46129 <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed
326 lines
11 KiB
C#
326 lines
11 KiB
C#
// 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.Diagnostics.CodeAnalysis;
|
|
using Microsoft.CmdPal.UI.ViewModels.Commands;
|
|
using Microsoft.CmdPal.UI.ViewModels.Models;
|
|
using Microsoft.CommandPalette.Extensions;
|
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public partial class ListItemViewModel : CommandItemViewModel
|
|
{
|
|
public new ExtensionObject<IListItem> Model { get; }
|
|
|
|
public List<TagViewModel>? Tags { get; set; }
|
|
|
|
// Remember - "observable" properties from the model (via PropChanged)
|
|
// cannot be marked [ObservableProperty]
|
|
public bool HasTags => (Tags?.Count ?? 0) > 0;
|
|
|
|
public string TextToSuggest { get; private set; } = string.Empty;
|
|
|
|
public string Section { get; private set; } = string.Empty;
|
|
|
|
public ListItemType Type { get; private set; }
|
|
|
|
public bool IsInteractive => Type == ListItemType.Item;
|
|
|
|
public DetailsViewModel? Details { get; private set; }
|
|
|
|
[MemberNotNullWhen(true, nameof(Details))]
|
|
public bool HasDetails => Details is not null;
|
|
|
|
public string AccessibleName { get; private set; } = string.Empty;
|
|
|
|
public bool ShowTitle { get; private set; }
|
|
|
|
public bool ShowSubtitle { get; private set; }
|
|
|
|
public bool LayoutShowsTitle
|
|
{
|
|
get;
|
|
set
|
|
{
|
|
if (SetProperty(ref field, value))
|
|
{
|
|
UpdateShowsTitle();
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool LayoutShowsSubtitle
|
|
{
|
|
get;
|
|
set
|
|
{
|
|
if (SetProperty(ref field, value))
|
|
{
|
|
UpdateShowsSubtitle();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory contextMenuFactory)
|
|
: base(new(model), context, contextMenuFactory)
|
|
{
|
|
Model = new ExtensionObject<IListItem>(model);
|
|
}
|
|
|
|
public override void InitializeProperties()
|
|
{
|
|
if (IsInitialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// This sets IsInitialized = true
|
|
base.InitializeProperties();
|
|
|
|
var li = Model.Unsafe;
|
|
if (li is null)
|
|
{
|
|
return; // throw?
|
|
}
|
|
|
|
UpdateTags(li.Tags);
|
|
Section = li.Section ?? string.Empty;
|
|
Type = EvaluateType();
|
|
UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive));
|
|
|
|
UpdateAccessibleName();
|
|
}
|
|
|
|
private ListItemType EvaluateType()
|
|
{
|
|
return Command.IsSet
|
|
? ListItemType.Item
|
|
: string.IsNullOrEmpty(Section) ? ListItemType.Separator : ListItemType.SectionHeader;
|
|
}
|
|
|
|
public override void SlowInitializeProperties()
|
|
{
|
|
base.SlowInitializeProperties();
|
|
var model = Model.Unsafe;
|
|
if (model is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var extensionDetails = model.Details;
|
|
if (extensionDetails is not null)
|
|
{
|
|
Details = new(extensionDetails, PageContext);
|
|
Details.InitializeProperties();
|
|
UpdateProperty(nameof(Details), nameof(HasDetails));
|
|
}
|
|
|
|
AddShowDetailsCommands();
|
|
|
|
TextToSuggest = model.TextToSuggest;
|
|
UpdateProperty(nameof(TextToSuggest));
|
|
}
|
|
|
|
protected override void FetchProperty(string propertyName)
|
|
{
|
|
base.FetchProperty(propertyName);
|
|
|
|
var model = this.Model.Unsafe;
|
|
if (model is null)
|
|
{
|
|
return; // throw?
|
|
}
|
|
|
|
switch (propertyName)
|
|
{
|
|
case nameof(model.Tags):
|
|
UpdateTags(model.Tags);
|
|
break;
|
|
case nameof(model.TextToSuggest):
|
|
TextToSuggest = model.TextToSuggest ?? string.Empty;
|
|
UpdateProperty(nameof(TextToSuggest));
|
|
break;
|
|
case nameof(model.Section):
|
|
Section = model.Section ?? string.Empty;
|
|
Type = EvaluateType();
|
|
UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive));
|
|
break;
|
|
case nameof(model.Command):
|
|
Type = EvaluateType();
|
|
UpdateProperty(nameof(Type), nameof(IsInteractive));
|
|
break;
|
|
case nameof(Details):
|
|
var extensionDetails = model.Details;
|
|
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
|
|
Details?.InitializeProperties();
|
|
UpdateProperty(nameof(Details), nameof(HasDetails));
|
|
UpdateShowDetailsCommand();
|
|
break;
|
|
case nameof(model.MoreCommands):
|
|
AddShowDetailsCommands();
|
|
break;
|
|
case nameof(model.Title):
|
|
UpdateProperty(nameof(Title));
|
|
UpdateShowsTitle();
|
|
UpdateAccessibleName();
|
|
break;
|
|
case nameof(model.Subtitle):
|
|
UpdateProperty(nameof(Subtitle));
|
|
UpdateShowsSubtitle();
|
|
UpdateAccessibleName();
|
|
break;
|
|
default:
|
|
UpdateProperty(propertyName);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
|
|
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
|
|
public override string ToString() => $"{Name} ListItemViewModel";
|
|
|
|
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
|
|
|
|
public override int GetHashCode() => Model.GetHashCode();
|
|
|
|
private void AddShowDetailsCommands()
|
|
{
|
|
// If the parent page has ShowDetails = false and we have details,
|
|
// then we should add a show details action in the context menu.
|
|
if (HasDetails &&
|
|
PageContext.TryGetTarget(out var pageContext) &&
|
|
pageContext is ListViewModel listViewModel &&
|
|
!listViewModel.ShowDetails)
|
|
{
|
|
var addedCommand = false;
|
|
lock (MoreCommandsLock)
|
|
{
|
|
// Check if "Show Details" action already exists to prevent duplicates
|
|
if (!UnsafeMoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel &&
|
|
contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId))
|
|
{
|
|
var showDetailsCommand = new ShowDetailsCommand(Details);
|
|
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
|
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
|
showDetailsContextItemViewModel.SlowInitializeProperties();
|
|
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
|
|
RefreshMoreCommandStateUnsafe();
|
|
addedCommand = true;
|
|
}
|
|
}
|
|
|
|
if (addedCommand)
|
|
{
|
|
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
|
|
UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands));
|
|
}
|
|
}
|
|
}
|
|
|
|
// This method is called when the details change to make sure we
|
|
// have the latest details in the show details command.
|
|
private void UpdateShowDetailsCommand()
|
|
{
|
|
// If the parent page has ShowDetails = false and we have details,
|
|
// then we should add a show details action in the context menu.
|
|
if (HasDetails &&
|
|
PageContext.TryGetTarget(out var pageContext) &&
|
|
pageContext is ListViewModel listViewModel &&
|
|
!listViewModel.ShowDetails)
|
|
{
|
|
CommandContextItemViewModel? oldCommand = null;
|
|
lock (MoreCommandsLock)
|
|
{
|
|
oldCommand = UnsafeMoreCommands
|
|
.OfType<CommandContextItemViewModel>()
|
|
.FirstOrDefault(contextItemViewModel => contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId);
|
|
|
|
if (oldCommand is not null)
|
|
{
|
|
UnsafeMoreCommands.Remove(oldCommand);
|
|
}
|
|
|
|
var showDetailsCommand = new ShowDetailsCommand(Details);
|
|
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
|
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
|
showDetailsContextItemViewModel.SlowInitializeProperties();
|
|
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
|
|
RefreshMoreCommandStateUnsafe();
|
|
}
|
|
|
|
oldCommand?.SafeCleanup();
|
|
|
|
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
|
|
UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands));
|
|
}
|
|
}
|
|
|
|
private void UpdateTags(ITag[]? newTagsFromModel)
|
|
{
|
|
var newTags = newTagsFromModel?.Select(t =>
|
|
{
|
|
var vm = new TagViewModel(t, PageContext);
|
|
vm.InitializeProperties();
|
|
return vm;
|
|
})
|
|
.ToList() ?? [];
|
|
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
// Tags being an ObservableCollection instead of a List lead to
|
|
// many COM exception issues.
|
|
Tags = [.. newTags];
|
|
|
|
// We're already in UI thread, so just raise the events
|
|
OnPropertyChanged(nameof(Tags));
|
|
OnPropertyChanged(nameof(HasTags));
|
|
});
|
|
}
|
|
|
|
private void UpdateShowsTitle()
|
|
{
|
|
var oldShowTitle = ShowTitle;
|
|
ShowTitle = LayoutShowsTitle;
|
|
if (oldShowTitle != ShowTitle)
|
|
{
|
|
UpdateProperty(nameof(ShowTitle));
|
|
}
|
|
}
|
|
|
|
private void UpdateShowsSubtitle()
|
|
{
|
|
var oldShowSubtitle = ShowSubtitle;
|
|
ShowSubtitle = LayoutShowsSubtitle && !string.IsNullOrWhiteSpace(Subtitle);
|
|
if (oldShowSubtitle != ShowSubtitle)
|
|
{
|
|
UpdateProperty(nameof(ShowSubtitle));
|
|
}
|
|
}
|
|
|
|
protected override void UnsafeCleanup()
|
|
{
|
|
base.UnsafeCleanup();
|
|
|
|
// Tags don't have event handlers or anything to cleanup
|
|
Tags?.ForEach(t => t.SafeCleanup());
|
|
Details?.SafeCleanup();
|
|
|
|
var model = Model.Unsafe;
|
|
if (model is not null)
|
|
{
|
|
// We don't need to revoke the PropChanged event handler here,
|
|
// because we are just overriding CommandItem's FetchProperty and
|
|
// piggy-backing off their PropChanged
|
|
}
|
|
}
|
|
|
|
protected void UpdateAccessibleName()
|
|
{
|
|
AccessibleName = Title + ", " + Subtitle;
|
|
UpdateProperty(nameof(AccessibleName));
|
|
}
|
|
}
|