/
githubmirror
/
PowerToys
Обзор
Документация
Войти
/
githubmirror
/
PowerToys
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs
742 строки
25 KB
Jiří Polášek
CmdPal: Clean up replaced command view models (#49730)
08 авг 2026, 21:05
Не верифицирован
08 авг 2026, 21:05
a18779e
Код
Авторство
О чём код?
// 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.Common; using Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.Common.Text; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.ApplicationModel.DataTransfer; namespace Microsoft.CmdPal.UI.ViewModels; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext, IPrecomputedListItem { public ExtensionObject<ICommandItem> Model => _commandItemModel; private readonly IContextMenuFactory? _contextMenuFactory; private readonly Lock _moreCommandsLock = new(); private readonly List<IContextItemViewModel> _moreCommands = []; private volatile CommandContextItemViewModel? _secondaryMoreCommand; private volatile IContextItemViewModel[] _moreCommandsSnapshot = []; private volatile IContextItemViewModel[] _allCommandsSnapshot = []; private ExtensionObject<IExtendedAttributesProvider>? ExtendedAttributesProvider { get; set; } private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null); private CommandContextItemViewModel? _defaultCommandContextItemViewModel; private FuzzyTargetCache _titleCache; private FuzzyTargetCache _subtitleCache; internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; protected bool IsFastInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.FastInitialized); protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized); protected bool IsSelectedInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.SelectionInitialized); public bool IsContextMenuItem { get; protected init; } public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error); // These are properties that are "observable" from the extension object // itself, in the sense that they get raised by PropChanged events from the // extension. However, we don't want to actually make them // [ObservableProperty]s, because PropChanged comes in off the UI thread, // and ObservableProperty is not smart enough to raise the PropertyChanged // on the UI thread. public string Name => Command.Name; private string _itemTitle = string.Empty; protected string ItemTitle => _itemTitle; public virtual string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle; public virtual string Subtitle { get; private set; } = string.Empty; private IconInfoViewModel _icon = new(null); public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon; /// <summary> /// The command and whether we own it, held as a single immutable pair. /// </summary> private CommandOwnership _commandState; /// <summary> /// Gets the command backing this item. /// </summary> /// <remarks> /// Read-only on purpose. Assigning it directly would silently drop the previous view-model without unsubscribing it, /// which strands it and its extension command across the process boundary for good. /// Mutate it through <see cref="ReplaceCommand"/> when this item owns the command, /// or <see cref="BorrowCommand"/> when it is holding one owned elsewhere. /// </remarks> public CommandViewModel Command => _commandState.Command; // Reuse a cached read-only snapshot so repeated reads don't allocate. public IReadOnlyList<IContextItemViewModel> MoreCommands => _moreCommandsSnapshot; IReadOnlyList<IContextItemViewModel> IContextMenuContext.MoreCommands => _moreCommandsSnapshot; protected Lock MoreCommandsLock => _moreCommandsLock; protected List<IContextItemViewModel> UnsafeMoreCommands => _moreCommands; public bool HasMoreCommands => _secondaryMoreCommand is not null; public string SecondaryCommandName => _secondaryMoreCommand?.Name ?? string.Empty; public CommandItemViewModel? PrimaryCommand => this; public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand; public bool CanOpenContextMenu => // BEAR LOADING: A visible synthetic primary command makes the item // context-openable immediately, even if out-of-proc MoreCommands are still // hydrating. Without this fast path, the first open request can race slow // menu initialization and get dropped. _defaultCommandContextItemViewModel?.ShouldBeVisible == true || _moreCommandsSnapshot.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible); public bool ShouldBeVisible => !string.IsNullOrEmpty(Name); public bool HasTitle => !string.IsNullOrEmpty(Title); public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle); public virtual bool HasText => HasTitle || HasSubtitle; public DataPackageView? DataPackage { get; private set; } public IReadOnlyList<IContextItemViewModel> AllCommands => _allCommandsSnapshot; private static readonly IconInfoViewModel _errorIcon; static CommandItemViewModel() { _errorIcon = new(new IconInfo("\uEA39")); // ErrorBadge _errorIcon.InitializeProperties(); } public CommandItemViewModel( ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext, IContextMenuFactory? contextMenuFactory) : base(errorContext) { _commandItemModel = item; _contextMenuFactory = contextMenuFactory; _commandState = new(new CommandViewModel(null, errorContext), Owned: true); } public void FastInitializeProperties() { if (IsFastInitialized) { return; } var model = _commandItemModel.Unsafe; if (model is null) { return; } var command = model.Command; ReplaceCommand(command); Command.FastInitializeProperties(); _itemTitle = model.Title; Subtitle = model.Subtitle; _titleCache.Invalidate(); _subtitleCache.Invalidate(); TryCreateDefaultCommandContextItem(command); Initialized |= InitializedState.FastInitialized; } //// Called from ListViewModel on background thread started in ListPage.xaml.cs public override void InitializeProperties() { if (IsInitialized) { return; } if (!IsFastInitialized) { FastInitializeProperties(); } var model = _commandItemModel.Unsafe; if (model is null) { return; } Command.InitializeProperties(); var icon = model.Icon; if (icon is not null) { _icon = new(icon); _icon.InitializeProperties(); } // TODO: Do these need to go into FastInit? model.PropChanged += Model_PropChanged; Command.PropertyChanged += Command_PropertyChanged; UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(Subtitle)); UpdateProperty(nameof(Icon)); // Load-bearing: if you don't raise a IsInitialized here, then // TopLevelViewModel will never know what the command's ID is, so it // will never be able to load Hotkeys & aliases UpdateProperty(nameof(IsInitialized)); if (model is IExtendedAttributesProvider extendedAttributesProvider) { ExtendedAttributesProvider = new ExtensionObject<IExtendedAttributesProvider>(extendedAttributesProvider); var properties = extendedAttributesProvider.GetProperties(); UpdateDataPackage(properties); } Initialized |= InitializedState.Initialized; } public virtual void SlowInitializeProperties() { if (IsSelectedInitialized) { return; } if (!IsInitialized) { InitializeProperties(); } var model = _commandItemModel.Unsafe; if (model is null) { return; } BuildAndInitMoreCommands(); TryCreateDefaultCommandContextItem(model.Command); lock (_moreCommandsLock) { RefreshMoreCommandStateUnsafe(); } Initialized |= InitializedState.SelectionInitialized; UpdateProperty(nameof(MoreCommands)); UpdateProperty(nameof(AllCommands)); UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands)); UpdateProperty(nameof(CanOpenContextMenu)); UpdateProperty(nameof(IsSelectedInitialized)); } public bool SafeFastInit() { try { FastInitializeProperties(); return true; } catch (Exception ex) { CoreLogger.LogError("error fast initializing CommandItemViewModel", ex); ReplaceCommand(null); _itemTitle = "Error"; Subtitle = "Item failed to load"; ClearMoreCommands(); _icon = _errorIcon; _titleCache.Invalidate(); _subtitleCache.Invalidate(); Initialized |= InitializedState.Error; } return false; } public bool SafeSlowInit() { try { SlowInitializeProperties(); return true; } catch (Exception ex) { Initialized |= InitializedState.Error; CoreLogger.LogError("error slow initializing CommandItemViewModel", ex); } return false; } public bool SafeInitializeProperties() { try { InitializeProperties(); return true; } catch (Exception ex) { CoreLogger.LogError("error initializing CommandItemViewModel", ex); ReplaceCommand(null); _itemTitle = "Error"; Subtitle = "Item failed to load"; ClearMoreCommands(); _icon = _errorIcon; _titleCache.Invalidate(); _subtitleCache.Invalidate(); Initialized |= InitializedState.Error; } return false; } private void Model_PropChanged(object sender, IPropChangedEventArgs args) { try { FetchProperty(args.PropertyName); } catch (Exception ex) { ShowException(ex, _commandItemModel?.Unsafe?.Title); } } protected virtual void FetchProperty(string propertyName) { var model = this._commandItemModel.Unsafe; if (model is null) { return; // throw? } switch (propertyName) { case nameof(Command): var command = model.Command; // ReplaceCommand detaches this item's handler from the command it // displaces, so there is no manual unsubscribe to remember here. ReplaceCommand(command); Command.InitializeProperties(); Command.PropertyChanged += Command_PropertyChanged; // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. _itemTitle = model.Title; if (_defaultCommandContextItemViewModel is not null) { _defaultCommandContextItemViewModel.BorrowCommand(Command); _defaultCommandContextItemViewModel.UpdateTitle(_itemTitle); UpdateDefaultContextItemIcon(); } else { TryCreateDefaultCommandContextItem(command); } UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(Icon)); UpdateProperty(nameof(HasText)); UpdateProperty(nameof(CanOpenContextMenu)); break; case nameof(Title): _itemTitle = model.Title; _titleCache.Invalidate(); UpdateProperty(nameof(HasText)); break; case nameof(Subtitle): var modelSubtitle = model.Subtitle; this.Subtitle = modelSubtitle; _defaultCommandContextItemViewModel?.Subtitle = modelSubtitle; _subtitleCache.Invalidate(); UpdateProperty(nameof(HasText)); break; case nameof(Icon): var oldIcon = _icon; _icon = new(model.Icon); _icon.InitializeProperties(); if (oldIcon.IsSet || _icon.IsSet) { UpdateProperty(nameof(Icon)); } UpdateDefaultContextItemIcon(); break; case nameof(model.MoreCommands): BuildAndInitMoreCommands(); UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands), nameof(AllCommands), nameof(CanOpenContextMenu)); break; case nameof(DataPackage): UpdateDataPackage(ExtendedAttributesProvider?.Unsafe?.GetProperties()); break; } UpdateProperty(propertyName); } private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { var propertyName = e.PropertyName; var model = _commandItemModel.Unsafe; if (model is null) { return; } switch (propertyName) { case nameof(Command.Name): // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. _itemTitle = model.Title; _titleCache.Invalidate(); UpdateProperty(nameof(Title), nameof(Name)); UpdateProperty(nameof(CanOpenContextMenu)); if (_defaultCommandContextItemViewModel is not null) { _defaultCommandContextItemViewModel.UpdateTitle(model.Command.Name); } else { TryCreateDefaultCommandContextItem(model.Command); } break; case nameof(Command.Icon): UpdateDefaultContextItemIcon(); UpdateProperty(nameof(Icon)); break; } } /// <summary> /// Creates <see cref="_defaultCommandContextItemViewModel"/> when it does not exist /// yet and the current command has a non-empty name. This covers the case /// where an extension initially exposes a <c>NoOpCommand</c> (empty name) /// and later switches to a concrete command after <see cref="SlowInitializeProperties"/> has already run. /// When a new instance is created, the snapshot is refreshed and /// <see cref="AllCommands"/> is notified. /// </summary> private void TryCreateDefaultCommandContextItem(ICommand? commandModel) { if (_defaultCommandContextItemViewModel is not null) { return; } // We only synthesize the primary entry when the command is already // usable; a null/empty primary must still fall back to late // MoreCommands-based opening. if (string.IsNullOrEmpty(Command.Name) || commandModel is null) { return; } var defaultContextItem = new CommandContextItemViewModel(new CommandContextItem(commandModel), PageContext) { _itemTitle = Name, Subtitle = Subtitle, // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel. }; // The synthesized entry stands in for this item's own command, so it // shares the view-model rather than building a second one for the same // extension object. defaultContextItem.BorrowCommand(Command); _defaultCommandContextItemViewModel = defaultContextItem; UpdateDefaultContextItemIcon(); lock (_moreCommandsLock) { RefreshMoreCommandStateUnsafe(); } UpdateProperty(nameof(AllCommands)); } private void UpdateDefaultContextItemIcon() => // Command icon takes precedence over our icon on the primary command _defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon); private void UpdateTitle(string? title) { _itemTitle = title ?? string.Empty; _titleCache.Invalidate(); UpdateProperty(nameof(Title)); } private void UpdateIcon(IIconInfo? iconInfo) { _icon = new(iconInfo); _icon.InitializeProperties(); UpdateProperty(nameof(Icon)); } private void UpdateDataPackage(IDictionary<string, object?>? properties) { DataPackage = properties?.TryGetValue(WellKnownExtensionAttributes.DataPackage, out var dataPackageView) == true && dataPackageView is DataPackageView view ? view : null; UpdateProperty(nameof(DataPackage)); } public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title); public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle); /// <summary> /// Replaces <see cref="Command"/> with a newly built view-model this item owns, cleaning up the one being dropped. /// </summary> /// <remarks> /// Takes the extension command instead of a view-model - the view-model is built here so that we can guarantee ownership.. /// </remarks> private void ReplaceCommand(ICommand? model) { var command = new CommandViewModel(model, PageContext); var replaced = Interlocked.Exchange(ref _commandState, new CommandOwnership(command, Owned: true)); ReleaseReplaced(replaced, command); } /// <summary> /// Points <see cref="Command"/> at a view-model owned by someone else. /// </summary> /// <remarks> /// The borrowed instance is never cleaned up here - that is its owner's job. /// </remarks> private void BorrowCommand(CommandViewModel command) { var replaced = Interlocked.Exchange(ref _commandState, new CommandOwnership(command, Owned: false)); ReleaseReplaced(replaced, command); } /// <summary> /// Cleans up a displaced command, if we owned it and it is not the one that just took its place. /// </summary> private void ReleaseReplaced(CommandOwnership replaced, CommandViewModel current) { if (ReferenceEquals(replaced.Command, current)) { return; } // Detach regardless of ownership: this item attaches its own handler to // whichever command it is showing, borrowed or not, so the handler has to // come off whenever that command is swapped out. replaced.Command.PropertyChanged -= Command_PropertyChanged; if (replaced.Owned) { replaced.Command.SafeCleanup(); } } /// <remarks> /// * Does call SlowInitializeProperties on the created items. /// * does NOT call UpdateProperty ; caller must do that. /// </remarks> private void BuildAndInitMoreCommands() { var model = _commandItemModel.Unsafe; if (model is null) { return; } var more = model.MoreCommands; var factory = _contextMenuFactory ?? DefaultContextMenuFactory.Instance; var results = factory.UnsafeBuildAndInitMoreCommands(more, this); List<IContextItemViewModel>? freedItems; lock (_moreCommandsLock) { ListHelpers.InPlaceUpdateList(_moreCommands, results, out freedItems); RefreshMoreCommandStateUnsafe(); } freedItems.OfType<CommandContextItemViewModel>() .ToList() .ForEach(c => c.SafeCleanup()); } public void RefreshMoreCommands() { Task.Run(RefreshMoreCommandsSynchronous); } private void RefreshMoreCommandsSynchronous() { try { BuildAndInitMoreCommands(); UpdateProperty(nameof(MoreCommands)); UpdateProperty(nameof(AllCommands)); UpdateProperty(nameof(SecondaryCommand)); UpdateProperty(nameof(SecondaryCommandName)); UpdateProperty(nameof(HasMoreCommands)); UpdateProperty(nameof(CanOpenContextMenu)); } catch (Exception ex) { // Handle any exceptions that might occur during the refresh process CoreLogger.LogError("Error refreshing MoreCommands in CommandItemViewModel", ex); ShowException(ex, _commandItemModel?.Unsafe?.Title); } } protected override void UnsafeCleanup() { base.UnsafeCleanup(); List<IContextItemViewModel> freedItems; CommandContextItemViewModel? freedDefault; lock (_moreCommandsLock) { freedItems = [.. _moreCommands]; _moreCommands.Clear(); // Null out here so the single RefreshMoreCommandStateUnsafe call // produces an _allCommandsSnapshot that excludes the default command. freedDefault = _defaultCommandContextItemViewModel; _defaultCommandContextItemViewModel = null; RefreshMoreCommandStateUnsafe(); } // Cleanup outside lock to avoid holding it during RPC calls freedItems.OfType<CommandContextItemViewModel>() .ToList() .ForEach(c => c.SafeCleanup()); freedDefault?.SafeCleanup(); // _listItemIcon.SafeCleanup(); _icon = new(null); // necessary? // One read of the pair, so a replacement racing this teardown cannot // leave us cleaning up a command against the wrong ownership flag. var commandState = _commandState; commandState.Command.PropertyChanged -= Command_PropertyChanged; // Only tear down a command this item built. The synthesized default // context item borrows its parent's, and cleaning that up from here // would pull it out from under an item that is still using it. if (commandState.Owned) { commandState.Command.SafeCleanup(); } var model = _commandItemModel.Unsafe; if (model is not null) { model.PropChanged -= Model_PropChanged; } } public override void SafeCleanup() { base.SafeCleanup(); Initialized |= InitializedState.CleanedUp; } protected void RefreshMoreCommandStateUnsafe() { _moreCommandsSnapshot = [.. _moreCommands]; _secondaryMoreCommand = null; foreach (var item in _moreCommands) { if (item is CommandContextItemViewModel command) { _secondaryMoreCommand = command; break; } } _allCommandsSnapshot = _defaultCommandContextItemViewModel is null ? _moreCommandsSnapshot : [_defaultCommandContextItemViewModel, .. _moreCommandsSnapshot]; } private void ClearMoreCommands() { List<IContextItemViewModel> freedItems; lock (_moreCommandsLock) { freedItems = [.. _moreCommands]; _moreCommands.Clear(); RefreshMoreCommandStateUnsafe(); } freedItems.OfType<CommandContextItemViewModel>() .ToList() .ForEach(c => c.SafeCleanup()); } /// <summary> /// A command together with whether this item is responsible for cleaning it up. /// </summary> /// <param name="Command">The command view-model.</param> /// <param name="Owned"> /// <see langword="true"/> when this item constructed <paramref name="Command"/>, /// <see langword="false"/> when it is borrowing one owned elsewhere. /// </param> private sealed record CommandOwnership(CommandViewModel Command, bool Owned); } [Flags] internal enum InitializedState { Uninitialized = 0, FastInitialized = 1, Initialized = 2, SelectionInitialized = 4, Error = 8, CleanedUp = 16, }