mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 19:27:56 +01:00
## Summary of the Pull Request This PR adds basic drag-and-drop support for items in list and grid views. It introduces two new properties on `ListItem`, backed by `IExtendedAttributesProvider`: `DataPackage` and `DataPackageView`. These properties are mutually exclusive. `DataPackage` serves as a convenience property allowing the item to retain the underlying object without risk of losing it. Across the extension boundary, only the immutable `DataPackageView` snapshot is transferred. When `DataPackage` is set, `DataPackageView` is derived from it. This PR includes initial concrete drag-and-drop implementations for: - File Indexer - Clipboard History **Todo / Missing pieces** - [x] Extend `DataPackage` support to top-level command items, enabling scenarios such as index fallback ~ - [x] Provide automatic drag-and-drop for unconfigured list items (e.g., copying title and subtitle as text) - [x] Keep CmdPal open - [ ] ~Clipboard commands (since we have the DataPackage...)~ - [ ] ~Improve logging~ ## Pictures? Moving ones! https://github.com/user-attachments/assets/13eb9a71-e760-43ea-8c2d-cd41cf377905 <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #38289 <!-- - [ ] 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
413 lines
12 KiB
C#
413 lines
12 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 CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using ManagedCommon;
|
|
using Microsoft.CmdPal.Core.ViewModels;
|
|
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
|
using Microsoft.CommandPalette.Extensions;
|
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Windows.Foundation;
|
|
using WyHash;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider
|
|
{
|
|
private readonly SettingsModel _settings;
|
|
private readonly ProviderSettings _providerSettings;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly CommandItemViewModel _commandItemViewModel;
|
|
|
|
private readonly string _commandProviderId;
|
|
|
|
private string IdFromModel => _commandItemViewModel.Command.Id;
|
|
|
|
private string _generatedId = string.Empty;
|
|
|
|
private HotkeySettings? _hotkey;
|
|
private IIconInfo? _initialIcon;
|
|
|
|
private CommandAlias? Alias { get; set; }
|
|
|
|
public bool IsFallback { get; private set; }
|
|
|
|
[ObservableProperty]
|
|
public partial ObservableCollection<Tag> Tags { get; set; } = [];
|
|
|
|
public string Id => string.IsNullOrEmpty(IdFromModel) ? _generatedId : IdFromModel;
|
|
|
|
public CommandPaletteHost ExtensionHost { get; private set; }
|
|
|
|
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
|
|
|
|
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
|
|
|
|
public string CommandProviderId => _commandProviderId;
|
|
|
|
////// ICommandItem
|
|
public string Title => _commandItemViewModel.Title;
|
|
|
|
public string Subtitle => _commandItemViewModel.Subtitle;
|
|
|
|
public IIconInfo Icon => _commandItemViewModel.Icon;
|
|
|
|
public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon;
|
|
|
|
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
|
|
|
|
IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands
|
|
.Select(item =>
|
|
{
|
|
if (item is ISeparatorContextItem)
|
|
{
|
|
return item as IContextItem;
|
|
}
|
|
else if (item is CommandContextItemViewModel commandItem)
|
|
{
|
|
return commandItem.Model.Unsafe;
|
|
}
|
|
else
|
|
{
|
|
return null;
|
|
}
|
|
}).ToArray();
|
|
|
|
////// IListItem
|
|
ITag[] IListItem.Tags => Tags.ToArray();
|
|
|
|
IDetails? IListItem.Details => null;
|
|
|
|
string IListItem.Section => string.Empty;
|
|
|
|
string IListItem.TextToSuggest => string.Empty;
|
|
|
|
////// INotifyPropChanged
|
|
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
|
|
|
// Fallback items
|
|
public string DisplayTitle { get; private set; } = string.Empty;
|
|
|
|
public HotkeySettings? Hotkey
|
|
{
|
|
get => _hotkey;
|
|
set
|
|
{
|
|
_serviceProvider.GetService<HotkeyManager>()!.UpdateHotkey(Id, value);
|
|
UpdateHotkey();
|
|
UpdateTags();
|
|
Save();
|
|
}
|
|
}
|
|
|
|
public bool HasAlias => !string.IsNullOrEmpty(AliasText);
|
|
|
|
public string AliasText
|
|
{
|
|
get => Alias?.Alias ?? string.Empty;
|
|
set
|
|
{
|
|
var previousAlias = Alias?.Alias ?? string.Empty;
|
|
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
Alias = null;
|
|
}
|
|
else
|
|
{
|
|
if (Alias is CommandAlias a)
|
|
{
|
|
a.Alias = value;
|
|
}
|
|
else
|
|
{
|
|
Alias = new CommandAlias(value, Id);
|
|
}
|
|
}
|
|
|
|
// Only call HandleChangeAlias if there was an actual change.
|
|
if (previousAlias != Alias?.Alias)
|
|
{
|
|
HandleChangeAlias();
|
|
OnPropertyChanged(nameof(AliasText));
|
|
OnPropertyChanged(nameof(IsDirectAlias));
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool IsDirectAlias
|
|
{
|
|
get => Alias?.IsDirect ?? false;
|
|
set
|
|
{
|
|
if (Alias is CommandAlias a)
|
|
{
|
|
a.IsDirect = value;
|
|
}
|
|
|
|
HandleChangeAlias();
|
|
OnPropertyChanged(nameof(IsDirectAlias));
|
|
}
|
|
}
|
|
|
|
public bool IsEnabled
|
|
{
|
|
get => _providerSettings.IsFallbackEnabled(this);
|
|
set
|
|
{
|
|
if (value != IsEnabled)
|
|
{
|
|
_providerSettings.SetFallbackEnabled(this, value);
|
|
Save();
|
|
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
|
}
|
|
}
|
|
}
|
|
|
|
public TopLevelViewModel(
|
|
CommandItemViewModel item,
|
|
bool isFallback,
|
|
CommandPaletteHost extensionHost,
|
|
string commandProviderId,
|
|
SettingsModel settings,
|
|
ProviderSettings providerSettings,
|
|
IServiceProvider serviceProvider)
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
_settings = settings;
|
|
_providerSettings = providerSettings;
|
|
_commandProviderId = commandProviderId;
|
|
_commandItemViewModel = item;
|
|
|
|
IsFallback = isFallback;
|
|
ExtensionHost = extensionHost;
|
|
|
|
item.PropertyChanged += Item_PropertyChanged;
|
|
|
|
// UpdateAlias();
|
|
// UpdateHotkey();
|
|
// UpdateTags();
|
|
}
|
|
|
|
internal void InitializeProperties()
|
|
{
|
|
ItemViewModel.SlowInitializeProperties();
|
|
|
|
if (IsFallback)
|
|
{
|
|
var model = _commandItemViewModel.Model.Unsafe;
|
|
|
|
// RPC to check type
|
|
if (model is IFallbackCommandItem fallback)
|
|
{
|
|
DisplayTitle = fallback.DisplayTitle;
|
|
}
|
|
|
|
UpdateInitialIcon(false);
|
|
}
|
|
}
|
|
|
|
private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (!string.IsNullOrEmpty(e.PropertyName))
|
|
{
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName));
|
|
|
|
if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command))
|
|
{
|
|
GenerateId();
|
|
|
|
FetchAliasFromAliasManager();
|
|
UpdateHotkey();
|
|
UpdateTags();
|
|
UpdateInitialIcon();
|
|
}
|
|
else if (e.PropertyName == nameof(CommandItem.Icon))
|
|
{
|
|
UpdateInitialIcon();
|
|
}
|
|
else if (e.PropertyName == nameof(CommandItem.DataPackage))
|
|
{
|
|
DoOnUiThread(() =>
|
|
{
|
|
OnPropertyChanged(nameof(CommandItem.DataPackage));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateInitialIcon(bool raiseNotification = true)
|
|
{
|
|
if (_initialIcon != null || !_commandItemViewModel.Icon.IsSet)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_initialIcon = _commandItemViewModel.Icon;
|
|
|
|
if (raiseNotification)
|
|
{
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(InitialIcon)));
|
|
});
|
|
}
|
|
}
|
|
|
|
private void Save() => SettingsModel.SaveSettings(_settings);
|
|
|
|
private void HandleChangeAlias()
|
|
{
|
|
SetAlias();
|
|
Save();
|
|
}
|
|
|
|
public void SetAlias()
|
|
{
|
|
var commandAlias = Alias is null
|
|
? null
|
|
: new CommandAlias(Alias.Alias, Alias.CommandId, Alias.IsDirect);
|
|
|
|
_serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, commandAlias);
|
|
UpdateTags();
|
|
}
|
|
|
|
private void FetchAliasFromAliasManager()
|
|
{
|
|
var am = _serviceProvider.GetService<AliasManager>();
|
|
if (am is not null)
|
|
{
|
|
var commandAlias = am.AliasFromId(Id);
|
|
if (commandAlias is not null)
|
|
{
|
|
// Decouple from the alias manager alias object
|
|
Alias = new CommandAlias(commandAlias.Alias, commandAlias.CommandId, commandAlias.IsDirect);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateHotkey()
|
|
{
|
|
var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault();
|
|
if (hotkey is not null)
|
|
{
|
|
_hotkey = hotkey.Hotkey;
|
|
}
|
|
}
|
|
|
|
private void UpdateTags()
|
|
{
|
|
List<Tag> tags = [];
|
|
|
|
if (Hotkey is not null)
|
|
{
|
|
tags.Add(new Tag() { Text = Hotkey.ToString() });
|
|
}
|
|
|
|
if (Alias is not null)
|
|
{
|
|
tags.Add(new Tag() { Text = Alias.SearchPrefix });
|
|
}
|
|
|
|
DoOnUiThread(
|
|
() =>
|
|
{
|
|
ListHelpers.InPlaceUpdateList(Tags, tags);
|
|
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Tags)));
|
|
});
|
|
}
|
|
|
|
private void GenerateId()
|
|
{
|
|
// Use WyHash64 to generate stable ID hashes.
|
|
// manually seeding with 0, so that the hash is stable across launches
|
|
var result = WyHash64.ComputeHash64(_commandProviderId + DisplayTitle + Title + Subtitle, seed: 0);
|
|
_generatedId = $"{_commandProviderId}{result}";
|
|
}
|
|
|
|
private void DoOnUiThread(Action action)
|
|
{
|
|
if (_commandItemViewModel.PageContext.TryGetTarget(out var pageContext))
|
|
{
|
|
Task.Factory.StartNew(
|
|
action,
|
|
CancellationToken.None,
|
|
TaskCreationOptions.None,
|
|
pageContext.Scheduler);
|
|
}
|
|
}
|
|
|
|
internal bool SafeUpdateFallbackTextSynchronous(string newQuery)
|
|
{
|
|
if (!IsFallback)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!IsEnabled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
return UnsafeUpdateFallbackSynchronous(newQuery);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex.ToString());
|
|
}
|
|
|
|
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)
|
|
{
|
|
var model = _commandItemViewModel.Model.Unsafe;
|
|
|
|
// RPC to check type
|
|
if (model is IFallbackCommandItem fallback)
|
|
{
|
|
var wasEmpty = string.IsNullOrEmpty(Title);
|
|
|
|
// RPC for method
|
|
fallback.FallbackHandler.UpdateQuery(newQuery);
|
|
var isEmpty = string.IsNullOrEmpty(Title);
|
|
return wasEmpty != isEmpty;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public PerformCommandMessage GetPerformCommandMessage()
|
|
{
|
|
return new PerformCommandMessage(this.CommandViewModel.Model, new Core.ViewModels.Models.ExtensionObject<IListItem>(this));
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}";
|
|
}
|
|
|
|
public IDictionary<string, object?> GetProperties()
|
|
{
|
|
return new Dictionary<string, object?>
|
|
{
|
|
[WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage,
|
|
};
|
|
}
|
|
}
|