Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs
Mike Griese cc16b61eb7 Create a Microsoft.CmdPal.Core.ViewModels project (#40560)
_targets #40504_ 

Major refactoring for #40113

This moves a large swath of the codebase to a `.Core` project. "Core"
doesn't have any explicit dependencies on "extensions", settings or the
current `MainListPage`. It's just a filterable list of stuff. This
should let us make this component a bit more reusable.

This is half of a PR. As I did this, I noticed a particular bit of code
for TopLevelVViewModels and CommandPaletteHost that was _very rough_.
Solving it in this PR would make "move everything to a new project" much
harder to review. So I'm submitting two PRs simultaneously, so we can
see the changes separately, then merge together.
2025-07-15 12:21:44 -05:00

294 lines
9.2 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.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
{
private readonly ExtensionObject<IContentPage> _model;
[ObservableProperty]
public partial ObservableCollection<ContentViewModel> Content { get; set; } = [];
public List<IContextItemViewModel> Commands { get; private set; } = [];
public bool HasCommands => ActualCommands.Count > 0;
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
public bool HasDetails => Details != null;
/////// ICommandBarContext ///////
public IEnumerable<IContextItemViewModel> MoreCommands => Commands.Skip(1);
private List<CommandContextItemViewModel> ActualCommands => Commands.OfType<CommandContextItemViewModel>().ToList();
public bool HasMoreCommands => ActualCommands.Count > 1;
public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
public CommandItemViewModel? PrimaryCommand => HasCommands ? ActualCommands[0] : null;
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[1] : null;
public List<IContextItemViewModel> AllCommands => Commands;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
_model = new(model);
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchContent();
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchContent()
{
List<ContentViewModel> newContent = [];
try
{
var newItems = _model.Unsafe!.GetContent();
foreach (var item in newItems)
{
var viewModel = ViewModelFromContent(item, PageContext);
if (viewModel != null)
{
viewModel.InitializeProperties();
newContent.Add(viewModel);
}
}
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
throw;
}
var oneContent = newContent.Count == 1;
newContent.ForEach(c => c.OnlyControlOnPage = oneContent);
// Now, back to a UI thread to update the observable collection
DoOnUiThread(
() =>
{
ListHelpers.InPlaceUpdateList(Content, newContent);
});
}
public virtual ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context)
{
// The core ContentPageViewModel doesn't actually handle any content,
// so we just return null here.
// The real content is handled by the derived class CommandPaletteContentPageViewModel
return null;
}
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model == null)
{
return; // throw?
}
Commands = model.Commands
.ToList()
.Select<IContextItem, IContextItemViewModel>(item =>
{
if (item is CommandContextItem contextItem)
{
return new CommandContextItemViewModel(contextItem, PageContext);
}
else
{
return new SeparatorContextItemViewModel();
}
})
.ToList();
Commands
.OfType<CommandContextItemViewModel>()
.ToList()
.ForEach(contextItem =>
{
contextItem.InitializeProperties();
});
var extensionDetails = model.Details;
if (extensionDetails != null)
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
}
UpdateDetails();
FetchContent();
model.ItemsChanged += Model_ItemsChanged;
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this));
});
}
protected override void FetchProperty(string propertyName)
{
base.FetchProperty(propertyName);
var model = this._model.Unsafe;
if (model == null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(Commands):
var more = model.Commands;
if (more != null)
{
var newContextMenu = more
.ToList()
.Select(item =>
{
if (item is CommandContextItem contextItem)
{
return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel;
}
else
{
return new SeparatorContextItemViewModel();
}
})
.ToList();
lock (Commands)
{
ListHelpers.InPlaceUpdateList(Commands, newContextMenu);
}
Commands
.OfType<CommandContextItemViewModel>()
.ToList()
.ForEach(contextItem =>
{
contextItem.SlowInitializeProperties();
});
}
else
{
Commands.Clear();
}
UpdateProperty(nameof(PrimaryCommand));
UpdateProperty(nameof(SecondaryCommand));
UpdateProperty(nameof(SecondaryCommandName));
UpdateProperty(nameof(HasCommands));
UpdateProperty(nameof(HasMoreCommands));
UpdateProperty(nameof(AllCommands));
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(this));
});
break;
case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails != null ? new(extensionDetails, PageContext) : null;
UpdateDetails();
break;
}
UpdateProperty(propertyName);
}
private void UpdateDetails()
{
UpdateProperty(nameof(Details));
UpdateProperty(nameof(HasDetails));
DoOnUiThread(
() =>
{
if (HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
});
}
// InvokeItemCommand is what this will be in Xaml due to source generator
// this comes in on Enter keypresses in the SearchBox
[RelayCommand]
private void InvokePrimaryCommand(ContentPageViewModel page)
{
if (PrimaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model));
}
}
// this comes in on Ctrl+Enter keypresses in the SearchBox
[RelayCommand]
private void InvokeSecondaryCommand(ContentPageViewModel page)
{
if (SecondaryCommand != null)
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model));
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
Details?.SafeCleanup();
Commands
.OfType<CommandContextItemViewModel>()
.ToList()
.ForEach(item => item.SafeCleanup());
Commands.Clear();
foreach (var item in Content)
{
item.SafeCleanup();
}
Content.Clear();
var model = _model.Unsafe;
if (model != null)
{
model.ItemsChanged -= Model_ItemsChanged;
}
}
}