diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs index 5356ddd90d..fecd6ec580 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs @@ -25,6 +25,7 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider "env", "environment", "manifest", + "log", }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); public IEnumerable GetRules() @@ -61,6 +62,11 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider return full; } + if (IsVersionSegment(file)) + { + return full; + } + string stem, ext; if (dot > 0 && dot < file.Length - 1) { @@ -106,4 +112,30 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider var maskedCount = Math.Max(1, stem.Length - keep); return stem[..keep] + new string('*', maskedCount); } + + private static bool IsVersionSegment(string file) + { + var dotIndex = file.IndexOf('.'); + if (dotIndex <= 0 || dotIndex == file.Length - 1) + { + return false; + } + + var hasDot = false; + foreach (var ch in file) + { + if (ch == '.') + { + hasDot = true; + continue; + } + + if (!char.IsDigit(ch)) + { + return false; + } + } + + return hasDot; + } } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs index 4c352ff892..6be99b6ff6 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs @@ -11,7 +11,9 @@ internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider { public IEnumerable GetRules() { - yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses"); + // Disabled for now as these rules can be too aggressive and cause over-sanitization, especially in scenarios like + // error report sanitization where we want to preserve as much useful information as possible while still protecting sensitive data. + // yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses"); yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)"); yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses"); yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses"); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs index 964c6d83df..ae8f167f7c 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs @@ -25,52 +25,56 @@ internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider private static partial Regex EmailRx(); [GeneratedRegex(""" - (?xi) - # ---------- boundaries ---------- - (? require separators between blocks (avoid plain big ints) - (?:\(\d{1,4}\)|\d{1,4}) - (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} - ) + (?:\(\d{1,4}\)|\d{1,4}) + (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} + ) - # ---------- optional extension ---------- - (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? + # ---------- optional extension ---------- + (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? - (?!-\w) # don't end just before '-letter'/'-digit' - """, - SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + # ---------- end boundary (allow whitespace/newlines at edges) ---------- + (?!-\w) # don't end just before '-letter'/'-digit' + (?!\w) # don't be immediately followed by a word char + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, + SanitizerDefaults.DefaultMatchTimeoutMs)] private static partial Regex PhoneRx(); [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs index 125d8d78f4..eab0a8522e 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs @@ -165,4 +165,6 @@ public interface IAppHostService AppExtensionHost GetDefaultHost(); AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost); + + CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext); } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandProviderContext.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandProviderContext.cs new file mode 100644 index 0000000000..948a911dd9 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandProviderContext.cs @@ -0,0 +1,12 @@ +// 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.CmdPal.Core.ViewModels; + +public sealed class CommandProviderContext +{ + public required string ProviderId { get; init; } + + public static CommandProviderContext Empty { get; } = new() { ProviderId = "" }; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs index b30d02ce83..0163868f9b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs @@ -47,8 +47,8 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] - public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host) - : base(model, scheduler, host) + public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext) + : base(model, scheduler, host, providerContext) { _model = new(model); } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index bbfbcadcea..d1f59e268b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -89,8 +89,8 @@ public partial class ListViewModel : PageViewModel, IDisposable } } - public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host) - : base(model, scheduler, host) + public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext) + : base(model, scheduler, host, providerContext) { _model = new(model); EmptyContent = new(new(null), PageContext); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs index 3e2cd420c2..ffd405c884 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.Core.ViewModels; public partial class LoadingPageViewModel : PageViewModel { public LoadingPageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost host) - : base(model, scheduler, host) + : base(model, scheduler, host, CommandProviderContext.Empty) { ModelIsLoading = true; IsInitialized = false; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs index 504eef6af1..e2427f811c 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs @@ -5,4 +5,4 @@ namespace Microsoft.CmdPal.Core.ViewModels; internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost) - : PageViewModel(null, scheduler, extensionHost); + : PageViewModel(null, scheduler, extensionHost, CommandProviderContext.Empty); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 3b08b9266b..27e3129cbb 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -76,13 +76,16 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public IconInfoViewModel Icon { get; protected set; } - public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost) + public CommandProviderContext ProviderContext { get; protected set; } + + public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, CommandProviderContext providerContext) : base(scheduler) { InitializeSelfAsPageContext(); _pageModel = new(model); Scheduler = scheduler; ExtensionHost = extensionHost; + ProviderContext = providerContext; Icon = new(null); ExtensionHost.StatusMessages.CollectionChanged += StatusMessages_CollectionChanged; @@ -275,5 +278,5 @@ public interface IPageViewModelFactoryService /// Indicates whether the page is not the top-level page. /// The command palette host that will host the page (for status messages) /// A new instance of the page view model. - PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host); + PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext); } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs index 62c70076ad..ff64e6f6ca 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -258,6 +258,7 @@ public partial class ShellViewModel : ObservableObject, } var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost); + var providerContext = _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext); _rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host); @@ -273,15 +274,15 @@ public partial class ShellViewModel : ObservableObject, // Telemetry: Track extension page navigation for session metrics if (host is not null) { - string extensionId = host.GetExtensionDisplayName() ?? "builtin"; - string commandId = command?.Id ?? "unknown"; - string commandName = command?.Name ?? "unknown"; + var extensionId = host.GetExtensionDisplayName() ?? "builtin"; + var commandId = command?.Id ?? "unknown"; + var commandName = command?.Name ?? "unknown"; WeakReferenceMessenger.Default.Send( new(extensionId, commandId, commandName, true, 0)); } // Construct our ViewModel of the appropriate type and pass it the UI Thread context. - var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!); + var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!, providerContext); if (pageViewModel is null) { CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}"); @@ -352,10 +353,10 @@ public partial class ShellViewModel : ObservableObject, // Telemetry: Track command execution time and success var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var command = message.Command.Unsafe; - string extensionId = host?.GetExtensionDisplayName() ?? "builtin"; - string commandId = command?.Id ?? "unknown"; - string commandName = command?.Name ?? "unknown"; - bool success = false; + var extensionId = host?.GetExtensionDisplayName() ?? "builtin"; + var commandId = command?.Id ?? "unknown"; + var commandName = command?.Name ?? "unknown"; + var success = false; try { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs index e3cd8a92d7..3312cac2f9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs @@ -9,8 +9,8 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class CommandPaletteContentPageViewModel : ContentPageViewModel { - public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host) - : base(model, scheduler, host) + public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext) + : base(model, scheduler, host, providerContext) { } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs index 90e70d7fef..0c8397b3c4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs @@ -17,12 +17,12 @@ public class CommandPalettePageViewModelFactory _scheduler = scheduler; } - public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host) + public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext) { return page switch { - IListPage listPage => new ListViewModel(listPage, _scheduler, host) { IsNested = nested }, - IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host), + IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext) { IsNested = nested }, + IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext), _ => null, }; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index d0f73f4a12..2e2c009db7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -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,6 +8,7 @@ using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.DependencyInjection; using Windows.Foundation; @@ -158,6 +159,9 @@ public sealed class CommandProviderWrapper UnsafePreCacheApiAdditions(two); } + // Load pinned commands from saved settings + var pinnedCommands = LoadPinnedCommands(model, providerSettings); + Id = model.Id; DisplayName = model.DisplayName; Icon = new(model.Icon); @@ -175,7 +179,7 @@ public sealed class CommandProviderWrapper Settings = new(model.Settings, this, _taskScheduler); // We do need to explicitly initialize commands though - InitializeCommands(commands, fallbacks, serviceProvider, pageContext); + InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider, pageContext); Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})"); } @@ -206,27 +210,34 @@ public sealed class CommandProviderWrapper } } - private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference pageContext) + private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, ICommandItem[] pinnedCommands, IServiceProvider serviceProvider, WeakReference pageContext) { var settings = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); - + var ourContext = GetProviderContext(); var makeAndAdd = (ICommandItem? i, bool fallback) => { CommandItemViewModel commandItemViewModel = new(new(i), pageContext); - TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i); + TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i); topLevelViewModel.InitializeProperties(); return topLevelViewModel; }; + var topLevelList = new List(); + if (commands is not null) { - TopLevelItems = commands - .Select(c => makeAndAdd(c, false)) - .ToArray(); + topLevelList.AddRange(commands.Select(c => makeAndAdd(c, false))); } + if (pinnedCommands is not null) + { + topLevelList.AddRange(pinnedCommands.Select(c => makeAndAdd(c, false))); + } + + TopLevelItems = topLevelList.ToArray(); + if (fallbacks is not null) { FallbackItems = fallbacks @@ -235,6 +246,32 @@ public sealed class CommandProviderWrapper } } + private ICommandItem[] LoadPinnedCommands(ICommandProvider model, ProviderSettings providerSettings) + { + var pinnedItems = new List(); + + if (model is ICommandProvider4 provider4) + { + foreach (var pinnedId in providerSettings.PinnedCommandIds) + { + try + { + var commandItem = provider4.GetCommandItem(pinnedId); + if (commandItem is not null) + { + pinnedItems.Add(commandItem); + } + } + catch (Exception e) + { + Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}"); + } + } + } + + return pinnedItems.ToArray(); + } + private void UnsafePreCacheApiAdditions(ICommandProvider2 provider) { var apiExtensions = provider.GetApiExtensionStubs(); @@ -248,6 +285,26 @@ public sealed class CommandProviderWrapper } } + public void PinCommand(string commandId, IServiceProvider serviceProvider) + { + var settings = serviceProvider.GetService()!; + var providerSettings = GetProviderSettings(settings); + + if (!providerSettings.PinnedCommandIds.Contains(commandId)) + { + providerSettings.PinnedCommandIds.Add(commandId); + SettingsModel.SaveSettings(settings); + + // Raise CommandsChanged so the TopLevelCommandManager reloads our commands + this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); + } + } + + public CommandProviderContext GetProviderContext() + { + return new() { ProviderId = ProviderId }; + } + public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid; public override int GetHashCode() => _commandProvider.GetHashCode(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs index fe440cdaa7..7b5d5f06c6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs @@ -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. @@ -31,7 +31,7 @@ public partial class CommandSettingsViewModel(ICommandSettings? _unsafeSettings, if (model.SettingsPage is not null) { - SettingsPage = new CommandPaletteContentPageViewModel(model.SettingsPage, mainThread, provider.ExtensionHost); + SettingsPage = new CommandPaletteContentPageViewModel(model.SettingsPage, mainThread, provider.ExtensionHost, provider.GetProviderContext()); SettingsPage.InitializeProperties(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PinCommandItemMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PinCommandItemMessage.cs new file mode 100644 index 0000000000..c9b9f00506 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PinCommandItemMessage.cs @@ -0,0 +1,9 @@ +// 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.CmdPal.UI.ViewModels.Messages; + +public record PinCommandItemMessage(string ProviderId, string CommandId) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs index 3bb9a43360..419d6fbbe5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs @@ -18,6 +18,8 @@ public class ProviderSettings public Dictionary FallbackCommands { get; set; } = new(); + public List PinnedCommandIds { get; set; } = []; + [JsonIgnore] public string ProviderDisplayName { get; set; } = string.Empty; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 4473a1e144..1de71f59a3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -22,6 +22,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class TopLevelCommandManager : ObservableObject, IRecipient, + IRecipient, IPageContext, IDisposable { @@ -42,6 +43,7 @@ public partial class TopLevelCommandManager : ObservableObject, _commandProviderCache = commandProviderCache; _taskScheduler = _serviceProvider.GetService()!; WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); } @@ -414,6 +416,21 @@ public partial class TopLevelCommandManager : ObservableObject, public void Receive(ReloadCommandsMessage message) => ReloadAllCommandsAsync().ConfigureAwait(false); + public void Receive(PinCommandItemMessage message) + { + var wrapper = LookupProvider(message.ProviderId); + wrapper?.PinCommand(message.CommandId, _serviceProvider); + } + + private CommandProviderWrapper? LookupProvider(string providerId) + { + lock (_commandProvidersLock) + { + return _builtInCommands.FirstOrDefault(w => w.ProviderId == providerId) + ?? _extensionCommandProviders.FirstOrDefault(w => w.ProviderId == providerId); + } + } + void IPageContext.ShowException(Exception ex, string? extensionHint) { var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager"); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 13b9423119..1fa9682f33 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -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. @@ -27,7 +27,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx private readonly IServiceProvider _serviceProvider; private readonly CommandItemViewModel _commandItemViewModel; - private readonly string _commandProviderId; + public CommandProviderContext ProviderContext { get; private set; } private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id; @@ -57,7 +57,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx public CommandItemViewModel ItemViewModel => _commandItemViewModel; - public string CommandProviderId => _commandProviderId; + public string CommandProviderId => ProviderContext.ProviderId; ////// ICommandItem public string Title => _commandItemViewModel.Title; @@ -190,7 +190,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx CommandItemViewModel item, bool isFallback, CommandPaletteHost extensionHost, - string commandProviderId, + CommandProviderContext commandProviderContext, SettingsModel settings, ProviderSettings providerSettings, IServiceProvider serviceProvider, @@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx _serviceProvider = serviceProvider; _settings = settings; _providerSettings = providerSettings; - _commandProviderId = commandProviderId; + ProviderContext = commandProviderContext; _commandItemViewModel = item; IsFallback = isFallback; @@ -358,8 +358,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx { // 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}"; + var result = WyHash64.ComputeHash64(CommandProviderId + DisplayTitle + Title + Subtitle, seed: 0); + _generatedId = $"{CommandProviderId}{result}"; } private void DoOnUiThread(Action action) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs index 7963aec154..ac0dfddf58 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs @@ -11,37 +11,42 @@ public sealed class WindowPosition /// /// Gets or sets left position in device pixels. /// - public int X { get; set; } + public int X { get; init; } /// /// Gets or sets top position in device pixels. /// - public int Y { get; set; } + public int Y { get; init; } /// /// Gets or sets width in device pixels. /// - public int Width { get; set; } + public int Width { get; init; } /// /// Gets or sets height in device pixels. /// - public int Height { get; set; } + public int Height { get; init; } /// /// Gets or sets width of the screen in device pixels where the window is located. /// - public int ScreenWidth { get; set; } + public int ScreenWidth { get; init; } /// /// Gets or sets height of the screen in device pixels where the window is located. /// - public int ScreenHeight { get; set; } + public int ScreenHeight { get; init; } /// /// Gets or sets DPI (dots per inch) of the display where the window is located. /// - public int Dpi { get; set; } + public int Dpi { get; init; } + + /// + /// Gets a value indicating whether the width and height of the window are valid (greater than 0). + /// + public bool IsSizeValid => Width > 0 && Height > 0; /// /// Converts the window position properties to a structure representing the physical window rectangle. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs index bf8af589a6..766c4bf17c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs @@ -18,7 +18,7 @@ internal static class WindowPositionHelper private const int MinimumVisibleSize = 100; private const int DefaultDpi = 96; - public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) + public static RectInt32? CenterOnDisplay(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) { if (displayArea is null) { @@ -32,15 +32,9 @@ internal static class WindowPositionHelper } var targetDpi = GetDpiForDisplay(displayArea); - var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi); - - // Clamp to work area - var width = Math.Min(predictedSize.Width, workArea.Width); - var height = Math.Min(predictedSize.Height, workArea.Height); - - return new PointInt32( - workArea.X + ((workArea.Width - width) / 2), - workArea.Y + ((workArea.Height - height) / 2)); + var scaledSize = ScaleSize(windowSize, windowDpi, targetDpi); + var clampedSize = ClampSize(scaledSize.Width, scaledSize.Height, workArea); + return CenterRectInWorkArea(clampedSize, workArea); } /// @@ -74,6 +68,10 @@ internal static class WindowPositionHelper savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight }; } + // Remember the original size before DPI scaling - needed to compute + // gaps relative to the old screen when repositioning across displays. + var originalSize = new SizeInt32(savedRect.Width, savedRect.Height); + if (targetDpi != savedDpi) { savedRect = ScaleRect(savedRect, savedDpi, targetDpi); @@ -81,12 +79,17 @@ internal static class WindowPositionHelper var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea); - var shouldRecenter = hasInvalidSize || - IsOffscreen(savedRect, workArea) || - savedScreenSize.Width != workArea.Width || - savedScreenSize.Height != workArea.Height; + if (hasInvalidSize) + { + return CenterRectInWorkArea(clampedSize, workArea); + } - if (shouldRecenter) + if (savedScreenSize.Width != workArea.Width || savedScreenSize.Height != workArea.Height) + { + return RepositionRelativeToWorkArea(savedRect, savedScreenSize, originalSize, clampedSize, workArea); + } + + if (IsOffscreen(savedRect, workArea)) { return CenterRectInWorkArea(clampedSize, workArea); } @@ -126,27 +129,92 @@ internal static class WindowPositionHelper private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi) { + if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi) + { + return rect; + } + + // Don't scale position, that's absolute coordinates in virtual screen space var scale = (double)toDpi / fromDpi; return new RectInt32( - (int)Math.Round(rect.X * scale), - (int)Math.Round(rect.Y * scale), + rect.X, + rect.Y, (int)Math.Round(rect.Width * scale), (int)Math.Round(rect.Height * scale)); } - private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) => - new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height)); + private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) + { + return new SizeInt32(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height)); + } - private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) => - new( + private static RectInt32 RepositionRelativeToWorkArea(RectInt32 savedRect, SizeInt32 savedScreenSize, SizeInt32 originalSize, SizeInt32 clampedSize, RectInt32 workArea) + { + // Treat each axis as a 3-zone grid (start / center / end) so that + // edge-snapped windows stay snapped and centered windows stay centered. + // We don't store the old work area origin, so we use the current one as a + // best estimate (correct when the same physical display changed resolution/DPI/taskbar). + var newX = ScaleAxisByZone(savedRect.X, originalSize.Width, clampedSize.Width, workArea.X, savedScreenSize.Width, workArea.Width); + var newY = ScaleAxisByZone(savedRect.Y, originalSize.Height, clampedSize.Height, workArea.Y, savedScreenSize.Height, workArea.Height); + + newX = Math.Clamp(newX, workArea.X, Math.Max(workArea.X, workArea.X + workArea.Width - clampedSize.Width)); + newY = Math.Clamp(newY, workArea.Y, Math.Max(workArea.Y, workArea.Y + workArea.Height - clampedSize.Height)); + + return new RectInt32(newX, newY, clampedSize.Width, clampedSize.Height); + } + + /// + /// Repositions a window along one axis using a 3-zone model (start / center / end). + /// The zone is determined by which third of the old screen the window center falls in. + /// Uses (pre-DPI-scaling) for gap calculations against + /// the old screen, and (post-scaling) for placement on the new screen. + /// + private static int ScaleAxisByZone(int savedPos, int oldWindowSize, int newWindowSize, int workAreaOrigin, int oldScreenSize, int newScreenSize) + { + if (oldScreenSize <= 0 || newScreenSize <= 0) + { + return savedPos; + } + + var gapFromStart = savedPos - workAreaOrigin; + var windowCenter = gapFromStart + (oldWindowSize / 2); + + if (windowCenter >= oldScreenSize / 3 && windowCenter <= oldScreenSize * 2 / 3) + { + // Center zone - keep centered + return workAreaOrigin + ((newScreenSize - newWindowSize) / 2); + } + + var gapFromEnd = oldScreenSize - gapFromStart - oldWindowSize; + + if (gapFromStart <= gapFromEnd) + { + // Start zone - preserve proportional distance from start edge + var rel = (double)gapFromStart / oldScreenSize; + return workAreaOrigin + (int)Math.Round(rel * newScreenSize); + } + else + { + // End zone - preserve proportional distance from end edge + var rel = (double)gapFromEnd / oldScreenSize; + return workAreaOrigin + newScreenSize - newWindowSize - (int)Math.Round(rel * newScreenSize); + } + } + + private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) + { + return new RectInt32( workArea.X + ((workArea.Width - size.Width) / 2), workArea.Y + ((workArea.Height - size.Height) / 2), size.Width, size.Height); + } - private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) => - rect.X + MinimumVisibleSize > workArea.X + workArea.Width || - rect.X + rect.Width - MinimumVisibleSize < workArea.X || - rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height || - rect.Y + rect.Height - MinimumVisibleSize < workArea.Y; + private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) + { + return rect.X + MinimumVisibleSize > workArea.X + workArea.Width || + rect.X + rect.Width - MinimumVisibleSize < workArea.X || + rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height || + rect.Y + rect.Height - MinimumVisibleSize < workArea.Y; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index a6900773ab..2a63d5a2aa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -72,6 +72,7 @@ public sealed partial class MainWindow : WindowEx, private readonly IThemeService _themeService; private readonly WindowThemeSynchronizer _windowThemeSynchronizer; private bool _ignoreHotKeyWhenFullScreen = true; + private bool _suppressDpiChange; private bool _themeServiceInitialized; // Session tracking for telemetry @@ -127,6 +128,16 @@ public sealed partial class MainWindow : WindowEx, _keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon)); + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _hotkeyWndProc = HotKeyPrc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); + _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); + this.SetIcon(); AppWindow.Title = RS_.GetString("AppName"); RestoreWindowPosition(); @@ -153,16 +164,6 @@ public sealed partial class MainWindow : WindowEx, SizeChanged += WindowSizeChanged; RootElement.Loaded += RootElementLoaded; - WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); - - // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a - // member (and instead like, use a local), then the pointer we marshal - // into the WindowLongPtr will be useless after we leave this function, - // and our **WindProc will explode**. - _hotkeyWndProc = HotKeyPrc; - var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); - _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); - // Load our settings, and then also wire up a settings changed handler HotReloadSettings(); App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; @@ -213,6 +214,11 @@ public sealed partial class MainWindow : WindowEx, // Now that our content has loaded, we can update our draggable regions UpdateRegionsForCustomTitleBar(); + // Also update regions when DPI changes. SizeChanged only fires when the logical + // (DIP) size changes — a DPI change that scales the physical size while preserving + // the DIP size won't trigger it, leaving drag regions at the old physical coordinates. + RootElement.XamlRoot.Changed += XamlRoot_Changed; + // Add dev ribbon if enabled if (!BuildInfo.IsCiBuild) { @@ -221,6 +227,8 @@ public sealed partial class MainWindow : WindowEx, } } + private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); + private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); private void PositionCentered() @@ -231,16 +239,14 @@ public sealed partial class MainWindow : WindowEx, private void PositionCentered(DisplayArea displayArea) { - var position = WindowPositionHelper.CalculateCenteredPosition( + var rect = WindowPositionHelper.CenterOnDisplay( displayArea, AppWindow.Size, (int)this.GetDpiForWindow()); - if (position is not null) + if (rect is not null) { - // Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED; - // the helper already accounts for this when calculating the centered position. - AppWindow.Move((PointInt32)position); + MoveAndResizeDpiAware(rect.Value); } } @@ -249,29 +255,62 @@ public sealed partial class MainWindow : WindowEx, var settings = App.Current.Services.GetService(); if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition) { + // don't try to restore if the saved position is invalid, just recenter PositionCentered(); return; } - // MoveAndResize is safe here—we're restoring a saved state at startup, - // not moving a live window between displays. var newRect = WindowPositionHelper.AdjustRectForVisibility( savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi); - AppWindow.MoveAndResize(newRect); + MoveAndResizeDpiAware(newRect); + } + + /// + /// Moves and resizes the window while suppressing WM_DPICHANGED. + /// The caller is expected to provide a rect already scaled for the target display's DPI. + /// Without suppression, the framework would apply its own DPI scaling on top, double-scaling the window. + /// + private void MoveAndResizeDpiAware(RectInt32 rect) + { + var originalMinHeight = MinHeight; + var originalMinWidth = MinWidth; + + _suppressDpiChange = true; + + try + { + // WindowEx is uses current DPI to calculate the minimum window size + MinHeight = 0; + MinWidth = 0; + AppWindow.MoveAndResize(rect); + } + finally + { + MinHeight = originalMinHeight; + MinWidth = originalMinWidth; + _suppressDpiChange = false; + } } private void UpdateWindowPositionInMemory() { + var placement = new WINDOWPLACEMENT { length = (uint)Marshal.SizeOf() }; + if (!PInvoke.GetWindowPlacement(_hwnd, ref placement)) + { + return; + } + + var rect = placement.rcNormalPosition; var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; _currentWindowPosition = new WindowPosition { - X = AppWindow.Position.X, - Y = AppWindow.Position.Y, - Width = AppWindow.Size.Width, - Height = AppWindow.Size.Height, + X = rect.X, + Y = rect.Y, + Width = rect.Width, + Height = rect.Height, Dpi = (int)this.GetDpiForWindow(), ScreenWidth = displayArea.WorkArea.Width, ScreenHeight = displayArea.WorkArea.Height, @@ -480,7 +519,7 @@ public sealed partial class MainWindow : WindowEx, { var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight); var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi); - AppWindow.MoveAndResize(newRect); + MoveAndResizeDpiAware(newRect); } else { @@ -737,18 +776,12 @@ public sealed partial class MainWindow : WindowEx, var settings = serviceProvider.GetService(); if (settings is not null) { - settings.LastWindowPosition = new WindowPosition + // a quick sanity check, so we don't overwrite correct values + if (_currentWindowPosition.IsSizeValid) { - X = _currentWindowPosition.X, - Y = _currentWindowPosition.Y, - Width = _currentWindowPosition.Width, - Height = _currentWindowPosition.Height, - Dpi = _currentWindowPosition.Dpi, - ScreenWidth = _currentWindowPosition.ScreenWidth, - ScreenHeight = _currentWindowPosition.ScreenHeight, - }; - - SettingsModel.SaveSettings(settings); + settings.LastWindowPosition = _currentWindowPosition; + SettingsModel.SaveSettings(settings); + } } var extensionService = serviceProvider.GetService()!; @@ -1108,6 +1141,13 @@ public sealed partial class MainWindow : WindowEx, // Prevent the window from maximizing when double-clicking the title bar area case PInvoke.WM_NCLBUTTONDBLCLK: return (LRESULT)IntPtr.Zero; + + // When restoring a saved position across monitors with different DPIs, + // MoveAndResize already sets the correctly-scaled size. Suppress the + // framework's automatic DPI resize to avoid double-scaling. + case PInvoke.WM_DPICHANGED when _suppressDpiChange: + return (LRESULT)IntPtr.Zero; + case PInvoke.WM_HOTKEY: { var hotkeyIndex = (int)wParam.Value; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index 513db65b1a..6bb010e890 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -66,4 +66,8 @@ GetStockObject GetModuleHandle GetWindowThreadProcessId -AttachThreadInput \ No newline at end of file +AttachThreadInput + +GetWindowPlacement +WINDOWPLACEMENT +WM_DPICHANGED \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs index 7edf0a34c9..95a2c24aa6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs @@ -26,4 +26,15 @@ internal sealed class PowerToysAppHostService : IAppHostService return topLevelHost ?? currentHost ?? CommandPaletteHost.Instance; } + + public CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext) + { + CommandProviderContext? topLevelId = null; + if (command is TopLevelViewModel topLevelViewModel) + { + topLevelId = topLevelViewModel.ProviderContext; + } + + return topLevelId ?? currentContext ?? throw new InvalidOperationException("No command provider context could be found for the given command, and no current context was provided."); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs index 6d27172fa2..54c7ba92d7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs @@ -6,42 +6,42 @@ namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; public partial class ErrorReportSanitizerTests { - private static class TestData + internal static class TestData { internal static string Input => - $""" - HRESULT: 0x80004005 - HRESULT: -2147467259 + $""" + HRESULT: 0x80004005 + HRESULT: -2147467259 - Here is e-mail address - IPv4 address: 192.168.100.1 - IPv4 loopback address: 127.0.0.1 - MAC address: 00-14-22-01-23-45 - IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 - IPv6 loopback address: ::1 - Password: P@ssw0rd123! - Password=secret - Api key: 1234567890abcdef - PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb - InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com; - X-API-key: 1234567890abcdef - Pet-Shop-Subscription-Key: 1234567890abcdef - Here is a user name {Environment.UserName} - And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder - Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal - Here is machine name {Environment.MachineName} - JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 - User email john.doe@company.com failed validation - File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt - Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test - Phone number 555-123-4567 is invalid - API key abc123def456ghi789jkl012mno345pqr678 expired - Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123 - Error accessing file://C:/Users/john.doe/Documents/confidential.pdf - JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret - FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv - Email service error: mailto:admin@internal-company.com?subject=Alert - """; + Here is e-mail address + IPv4 address: 192.168.100.1 + IPv4 loopback address: 127.0.0.1 + MAC address: 00-14-22-01-23-45 + IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + IPv6 loopback address: ::1 + Password: P@ssw0rd123! + Password=secret + Api key: 1234567890abcdef + PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb + InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com; + X-API-key: 1234567890abcdef + Pet-Shop-Subscription-Key: 1234567890abcdef + Here is a user name {Environment.UserName} + And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder + Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal + Here is machine name {Environment.MachineName} + JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 + User email john.doe@company.com failed validation + File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt + Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test + Phone number 555-123-4567 is invalid + API key abc123def456ghi789jkl012mno345pqr678 expired + Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123 + Error accessing file://C:/Users/john.doe/Documents/confidential.pdf + JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret + FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv + Email service error: mailto:admin@internal-company.com?subject=Alert + """; public const string Expected = $""" @@ -49,8 +49,8 @@ public partial class ErrorReportSanitizerTests HRESULT: -2147467259 Here is e-mail address <[EMAIL_REDACTED]> - IPv4 address: [IP4_REDACTED] - IPv4 loopback address: [IP4_REDACTED] + IPv4 address: 192.168.100.1 + IPv4 loopback address: 127.0.0.1 MAC address: [MAC_ADDRESS_REDACTED] IPv6 address: [IP6_REDACTED] IPv6 loopback address: [IP6_REDACTED] @@ -77,5 +77,55 @@ public partial class ErrorReportSanitizerTests FTP upload error: [URL_REDACTED] Email service error: mailto:[EMAIL_REDACTED]?subject=Alert """; + + internal static string Input2 => + $""" + ============================================================ + Hello World! Command Palette is starting. + + Application: + App version: 0.0.1.0 + Packaging flavor: Packaged + Is elevated: no + + Environment: + OS version: Microsoft Windows 10.0.26220 + OS architecture: X64 + Runtime identifier: win-x64 + Framework: .NET 9.0.13 + Process architecture: X64 + Culture: cs-CZ + UI culture: en-US + + Paths: + Log directory: {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal\Logs\0.0.1.0 + Config directory: {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Packages\Microsoft.CommandPalette.Dev_8wekyb3d8bbwe\LocalState + ============================================================ + """; + + public const string Expected2 = + """ + ============================================================ + Hello World! Command Palette is starting. + + Application: + App version: 0.0.1.0 + Packaging flavor: Packaged + Is elevated: no + + Environment: + OS version: Microsoft Windows 10.0.26220 + OS architecture: X64 + Runtime identifier: win-x64 + Framework: .NET 9.0.13 + Process architecture: X64 + Culture: cs-CZ + UI culture: en-US + + Paths: + Log directory: [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal\Logs\0.0.1.0 + Config directory: [LOCALAPPLICATIONDATA_DIR]Packages\Microsoft.CommandPalette.Dev_8wekyb3d8bbwe\LocalState + ============================================================ + """; } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs index 1ab57acd2e..294279b5fc 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs @@ -22,4 +22,18 @@ public partial class ErrorReportSanitizerTests // Assert Assert.AreEqual(TestData.Expected, result); } + + [TestMethod] + public void Sanitize_ShouldNotMaskTooMuchPiiInErrorReport() + { + // Arrange + var reportSanitizer = new ErrorReportSanitizer(); + var input = TestData.Input2; + + // Act + var result = reportSanitizer.Sanitize(input); + + // Assert + Assert.AreEqual(TestData.Expected2, result); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs new file mode 100644 index 0000000000..6c5875fd60 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs @@ -0,0 +1,62 @@ +// 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.UnitTests.TestUtils; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +[TestClass] +public class FilenameMaskRuleProviderTests +{ + [TestMethod] + public void GetRules_ShouldReturnExpectedRules() + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var rules = provider.GetRules(); + + // Assert + var ruleList = new List(rules); + Assert.AreEqual(1, ruleList.Count); + Assert.AreEqual("Mask filename in any path", ruleList[0].Description); + } + + [DataTestMethod] + [DataRow(@"C:\Users\Alice\Documents\secret.txt", @"C:\Users\Alice\Documents\se****.txt")] + [DataRow(@"logs\error-report.log", @"logs\er**********.log")] + [DataRow(@"/var/logs/trace.json", @"/var/logs/tr***.json")] + public void FilenameRules_ShouldMaskFileNamesInPaths(string input, string expected) + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("C:\\Users\\Alice\\Documents\\", "C:\\Users\\Alice\\Documents\\")] + [DataRow(@"C:\Users\Alice\PowerToys\CmdPal\Logs\1.2.3.4", @"C:\Users\Alice\PowerToys\CmdPal\Logs\1.2.3.4")] + [DataRow(@"C:\Users\Alice\appsettings.json", @"C:\Users\Alice\appsettings.json")] + [DataRow(@"C:\Users\Alice\.env", @"C:\Users\Alice\.env")] + [DataRow(@"logs\readme", @"logs\readme")] + public void FilenameRules_ShouldNotMaskNonSensitivePatterns(string input, string expected) + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs index ac490f5a6b..3f2d3c92e7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs @@ -54,6 +54,8 @@ public class PiiRuleProviderTests [DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")] [DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")] [DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")] + [DataRow("Version 1.2.3.4", "Version 1.2.3.4")] + [DataRow("OS version: Microsoft Windows 10.0.26220", "OS version: Microsoft Windows 10.0.26220")] [DataRow("No phone number here", "No phone number here")] public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected) { @@ -104,6 +106,8 @@ public class PiiRuleProviderTests [DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")] [DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")] [DataRow("Version: 1.2.3", "Version: 1.2.3")] + [DataRow("Version: 1.2.3.4", "Version: 1.2.3.4")] + [DataRow("Version: 0.2.3.4", "Version: 0.2.3.4")] [DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")] [DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")] [DataRow("Date: 2023-10-05", "Date: 2023-10-05")] diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md index 57b5c4bd42..66ecbf34b6 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md +++ b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md @@ -77,6 +77,7 @@ functionality. - [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus) - [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2) - [Addenda IV: Dock bands](#addenda-iv-dock-bands) + - [Pinning nested commands to the dock (and top level)](#pinning-nested-commands-to-the-dock-and-top-level) - [Class diagram](#class-diagram) - [Future considerations](#future-considerations) - [Arbitrary parameters and arguments](#arbitrary-parameters-and-arguments) @@ -2128,6 +2129,36 @@ Users may choose to have: - Dock bands will still display the `Title` & `Subtitle` of each item in the band as the tooltip on those items, even when the "labels" are hidden. +### Pinning nested commands to the dock (and top level) + +We'll use another command provider method to allow the host to ask extensions +for items based on their ID. + +```csharp +interface ICommandProvider4 requires ICommandProvider3 +{ + ICommandItem GetCommandItem(String id); +}; +``` + +This will allow users to pin not just top-level commands, but also nested +commands which have an ID. The host can store that ID away, and then later ask +the extension for the `ICommandItem` with that ID, to get the full details of +the command to pin. + +This is needed separate from the `GetCommand` method on `ICommandProvider`, +because that method is was designed for two main purposes: + +* Short-circuiting the loading of top-level commands for frozen extensions. In + that case, DevPal would only need to look up the actual `ICommand` to perform + it. It wouldn't need the full `ICommandItem` with all the details. +* Allowing invokable commands to navigate using the GoToPageArgs. In that case, + DevPal would only need the `ICommand` to perform the navigation. + +In neither of those scenarios was the full "display" of the item needed. In +pinning scenarios, however, we need everything that the user would see in the UI +for that item, which is all in the `ICommandItem`. + ## Class diagram This is a diagram attempting to show the relationships between the various types we've defined for the SDK. Some elements are omitted for clarity. (Notably, `IconData` and `IPropChanged`, which are used in many places.) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 317087847e..a223dcab01 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -184,6 +184,20 @@ public partial class AllAppsCommandProvider : CommandProvider return null; } + public override ICommandItem? GetCommandItem(string id) + { + var items = _page.GetItems(); + foreach (var item in items) + { + if (item.Command.Id == id) + { + return item; + } + } + + return null; + } + private void OnPinStateChanged(object? sender, System.EventArgs e) { RaiseItemsChanged(0); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs index 9448cc0f2f..fbf086063b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs @@ -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. @@ -42,37 +42,61 @@ internal static class Commands var results = new List(); results.AddRange(new[] { - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0", runWithHiddenWindow: true))) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0", runWithHiddenWindow: true)) + { + Id = "com.microsoft.cmdpal.builtin.system.shutdown", + }) { Title = Resources.Microsoft_plugin_sys_shutdown_computer, Subtitle = Resources.Microsoft_plugin_sys_shutdown_computer_description, Icon = Icons.ShutdownIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0", runWithHiddenWindow: true))) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0", runWithHiddenWindow: true)) + { + Id = "com.microsoft.cmdpal.builtin.system.restart", + }) { Title = Resources.Microsoft_plugin_sys_restart_computer, Subtitle = Resources.Microsoft_plugin_sys_restart_computer_description, Icon = Icons.RestartIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_signout, confirmCommands, Resources.Microsoft_plugin_sys_sign_out_confirmation, () => NativeMethods.ExitWindowsEx(EWXLOGOFF, 0))) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_signout, confirmCommands, Resources.Microsoft_plugin_sys_sign_out_confirmation, () => NativeMethods.ExitWindowsEx(EWXLOGOFF, 0)) + { + Id = "com.microsoft.cmdpal.builtin.system.signout", + }) { Title = Resources.Microsoft_plugin_sys_sign_out, Subtitle = Resources.Microsoft_plugin_sys_sign_out_description, Icon = Icons.LogoffIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_lock, confirmCommands, Resources.Microsoft_plugin_sys_lock_confirmation, () => NativeMethods.LockWorkStation())) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_lock, confirmCommands, Resources.Microsoft_plugin_sys_lock_confirmation, () => NativeMethods.LockWorkStation()) + { + Id = "com.microsoft.cmdpal.builtin.system.lock", + }) { Title = Resources.Microsoft_plugin_sys_lock, Subtitle = Resources.Microsoft_plugin_sys_lock_description, Icon = Icons.LockIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_sleep, confirmCommands, Resources.Microsoft_plugin_sys_sleep_confirmation, () => NativeMethods.SetSuspendState(false, true, true))) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_sleep, confirmCommands, Resources.Microsoft_plugin_sys_sleep_confirmation, () => NativeMethods.SetSuspendState(false, true, true)) + { + Id = "com.microsoft.cmdpal.builtin.system.sleep", + }) { Title = Resources.Microsoft_plugin_sys_sleep, Subtitle = Resources.Microsoft_plugin_sys_sleep_description, Icon = Icons.SleepIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_hibernate, confirmCommands, Resources.Microsoft_plugin_sys_hibernate_confirmation, () => NativeMethods.SetSuspendState(true, true, true))) + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_hibernate, confirmCommands, Resources.Microsoft_plugin_sys_hibernate_confirmation, () => NativeMethods.SetSuspendState(true, true, true)) + { + Id = "com.microsoft.cmdpal.builtin.system.hibernate", + }) { Title = Resources.Microsoft_plugin_sys_hibernate, Subtitle = Resources.Microsoft_plugin_sys_hibernate_description, @@ -85,13 +109,19 @@ internal static class Commands { results.AddRange(new[] { - new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder")) + new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder") + { + Id = "com.microsoft.cmdpal.builtin.system.recycle_bin", + }) { Title = Resources.Microsoft_plugin_sys_RecycleBinOpen, Subtitle = Resources.Microsoft_plugin_sys_RecycleBin_description, Icon = Icons.RecycleBinIcon, }, - new ListItem(new EmptyRecycleBinConfirmation(emptyRBSuccessMessage)) + new ListItem(new EmptyRecycleBinConfirmation(emptyRBSuccessMessage) + { + Id = "com.microsoft.cmdpal.builtin.system.empty_recycle_bin", + }) { Title = Resources.Microsoft_plugin_sys_RecycleBinEmptyResult, Subtitle = Resources.Microsoft_plugin_sys_RecycleBinEmpty_description, @@ -102,7 +132,10 @@ internal static class Commands else { results.Add( - new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder")) + new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder") + { + Id = "com.microsoft.cmdpal.builtin.system.recycle_bin", + }) { Title = Resources.Microsoft_plugin_sys_RecycleBin, Subtitle = Resources.Microsoft_plugin_sys_RecycleBin_description, @@ -110,7 +143,15 @@ internal static class Commands }); } - results.Add(new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_sys_RestartShell_name!, confirmCommands, Resources.Microsoft_plugin_sys_RestartShell_confirmation!, static () => OpenInShellHelper.OpenInShell("cmd", "/C tskill explorer && start explorer", runWithHiddenWindow: true))) + results.Add(new ListItem( + new ExecuteCommandConfirmation( + Resources.Microsoft_plugin_sys_RestartShell_name!, + confirmCommands, + Resources.Microsoft_plugin_sys_RestartShell_confirmation!, + static () => OpenInShellHelper.OpenInShell("cmd", "/C tskill explorer && start explorer", runWithHiddenWindow: true)) + { + Id = "com.microsoft.cmdpal.builtin.system.restart_shell", + }) { Title = Resources.Microsoft_plugin_sys_RestartShell!, Subtitle = Resources.Microsoft_plugin_sys_RestartShell_description!, @@ -141,19 +182,19 @@ internal static class Commands var results = new List(); // We update the cache only if the last query is older than 'updateCacheIntervalSeconds' seconds - DateTime timeOfLastNetworkQueryBefore = timeOfLastNetworkQuery; + var timeOfLastNetworkQueryBefore = timeOfLastNetworkQuery; timeOfLastNetworkQuery = DateTime.Now; // Set time of last query to this query if ((timeOfLastNetworkQuery - timeOfLastNetworkQueryBefore).TotalSeconds >= UpdateCacheIntervalSeconds) { networkPropertiesCache = NetworkConnectionProperties.GetList(); } - CompositeFormat sysIpv4DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip4_description); - CompositeFormat sysIpv6DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip6_description); - CompositeFormat sysMacDescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_mac_description); + var sysIpv4DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip4_description); + var sysIpv6DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip6_description); + var sysMacDescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_mac_description); var hideDisconnectedNetworkInfo = manager.HideDisconnectedNetworkInfo(); - foreach (NetworkConnectionProperties intInfo in networkPropertiesCache) + foreach (var intInfo in networkPropertiesCache) { if (hideDisconnectedNetworkInfo) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs index 4bc86c209d..bf3a0f79e2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs @@ -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. @@ -39,4 +39,18 @@ public sealed partial class SystemCommandExtensionProvider : CommandProvider } public override IFallbackCommandItem[] FallbackCommands() => [_fallbackSystemItem]; + + public override ICommandItem? GetCommandItem(string id) + { + var everything = Page.GetItems(); + foreach (var item in everything) + { + if (item.Command.Id == id) + { + return item; + } + } + + return null; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs index 1614273d83..40777dd391 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs @@ -2,6 +2,8 @@ // 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 ManagedCommon; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; @@ -26,6 +28,22 @@ internal static class BrowserInfoServiceExtensions /// public static bool Open(this IBrowserInfoService browserInfoService, string url) { + // If the URL is a valid URI, attempt to open it with the default browser by invoking it through the shell. + if (Uri.TryCreate(url, UriKind.Absolute, out _)) + { + try + { + ShellHelpers.OpenInShell(url); + return true; + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to launch the URI {url}: {ex}"); + } + } + + // Use legacy method to open the URL if it's not a well-formed URI or if the shell launch fails. + // This may handle cases where the URL is a search query or a custom URI scheme. var defaultBrowser = browserInfoService.GetDefaultBrowser(); return defaultBrowser != null && ShellHelpers.OpenCommandInShell(defaultBrowser.Path, defaultBrowser.ArgumentsPattern, url); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs index 3ad4671263..769d7010f3 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs @@ -9,7 +9,8 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public abstract partial class CommandProvider : ICommandProvider, ICommandProvider2, - ICommandProvider3 + ICommandProvider3, + ICommandProvider4 { public virtual string Id { get; protected set; } = string.Empty; @@ -25,6 +26,8 @@ public abstract partial class CommandProvider : public virtual ICommand? GetCommand(string id) => null; + public virtual ICommandItem? GetCommandItem(string id) => null; + public virtual ICommandSettings? Settings { get; protected set; } public virtual bool Frozen { get; protected set; } = true; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl index dff98f6516..7836a3aac3 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl @@ -411,5 +411,11 @@ namespace Microsoft.CommandPalette.Extensions { ICommandItem[] GetDockBands(); }; - + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface ICommandProvider4 requires ICommandProvider3 + { + ICommandItem GetCommandItem(String id); + }; + }