From 75d85f80b9fdd80915caebef07f9f4cf39ad2d30 Mon Sep 17 00:00:00 2001 From: "Dustin L. Howett" Date: Thu, 21 Aug 2025 03:30:42 -0500 Subject: [PATCH 1/9] Remove versions and MS/System packages from NOTICE (#40620) We do not need to indicate that we consume System or Microsoft packages; it is expected that we do so because we are Microsoft and we are using .NET. We also don't need to maintain a second list of package versions that is bound to fall out of date. We absolutely do not need to cause build breaks when those package versions change because the build machine updated. Closes #23321 (by alternative construction) --- .../verifyNoticeMdAgainstNugetPackages.ps1 | 13 +- NOTICE.md | 136 ++++++------------ 2 files changed, 56 insertions(+), 93 deletions(-) diff --git a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 index e3120836c8..af9ab8ff6f 100644 --- a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 +++ b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 @@ -57,12 +57,19 @@ $totalList = $projFiles | ForEach-Object -Parallel { $p = -split $p $p = $p[1, 2] - $tempString = $p[0] + " " + $p[1] + $tempString = $p[0] - if(![string]::IsNullOrWhiteSpace($tempString)) + if([string]::IsNullOrWhiteSpace($tempString)) { - echo "- $tempString"; + Continue } + + if($tempString.StartsWith("Microsoft.") -Or $tempString.StartsWith("System.")) + { + Continue + } + + echo "- $tempString" } $csproj = $null; } diff --git a/NOTICE.md b/NOTICE.md index d75fe99522..058f0863b1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1491,93 +1491,49 @@ SOFTWARE. ## NuGet Packages used by PowerToys -- AdaptiveCards.ObjectModel.WinUI3 2.0.0-beta -- AdaptiveCards.Rendering.WinUI3 2.1.0-beta -- AdaptiveCards.Templating 2.0.5 -- Appium.WebDriver 4.4.5 -- Azure.AI.OpenAI 1.0.0-beta.17 -- CoenM.ImageSharp.ImageHash 1.3.6 -- CommunityToolkit.Common 8.4.0 -- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock 0.1.250703-build.2173 -- CommunityToolkit.Mvvm 8.4.0 -- CommunityToolkit.WinUI.Animations 8.2.250402 -- CommunityToolkit.WinUI.Collections 8.2.250402 -- CommunityToolkit.WinUI.Controls.Primitives 8.2.250402 -- CommunityToolkit.WinUI.Controls.Segmented 8.2.250402 -- CommunityToolkit.WinUI.Controls.SettingsControls 8.2.250402 -- CommunityToolkit.WinUI.Controls.Sizers 8.2.250402 -- CommunityToolkit.WinUI.Converters 8.2.250402 -- CommunityToolkit.WinUI.Extensions 8.2.250402 -- CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2 -- CommunityToolkit.WinUI.UI.Controls.Markdown 7.1.2 -- ControlzEx 6.0.0 -- HelixToolkit 2.24.0 -- HelixToolkit.Core.Wpf 2.24.0 -- hyjiacan.pinyin4net 4.1.1 -- Interop.Microsoft.Office.Interop.OneNote 1.1.0.2 -- LazyCache 2.4.0 -- Mages 3.0.0 -- Markdig.Signed 0.34.0 -- MessagePack 3.1.3 -- Microsoft.Bcl.AsyncInterfaces 9.0.8 -- Microsoft.Bot.AdaptiveExpressions.Core 4.23.0 -- Microsoft.CodeAnalysis.NetAnalyzers 9.0.0 -- Microsoft.Data.Sqlite 9.0.8 -- Microsoft.Diagnostics.Tracing.TraceEvent 3.1.16 -- Microsoft.DotNet.ILCompiler (A) -- Microsoft.Extensions.DependencyInjection 9.0.8 -- Microsoft.Extensions.Hosting 9.0.8 -- Microsoft.Extensions.Hosting.WindowsServices 9.0.8 -- Microsoft.Extensions.Logging 9.0.8 -- Microsoft.Extensions.Logging.Abstractions 9.0.8 -- Microsoft.NET.ILLink.Tasks (A) -- Microsoft.SemanticKernel 1.15.0 -- Microsoft.Toolkit.Uwp.Notifications 7.1.2 -- Microsoft.Web.WebView2 1.0.2903.40 -- Microsoft.Win32.SystemEvents 9.0.8 -- Microsoft.Windows.Compatibility 9.0.8 -- Microsoft.Windows.CsWin32 0.3.183 -- Microsoft.Windows.CsWinRT 2.2.0 -- Microsoft.Windows.SDK.BuildTools 10.0.26100.4188 -- Microsoft.WindowsAppSDK 1.7.250513003 -- Microsoft.WindowsPackageManager.ComInterop 1.10.340 -- Microsoft.Xaml.Behaviors.WinUI.Managed 2.0.9 -- Microsoft.Xaml.Behaviors.Wpf 1.1.39 -- ModernWpfUI 0.9.4 -- Moq 4.18.4 -- MSTest 3.8.3 -- NLog.Extensions.Logging 5.3.8 -- NLog.Schema 5.2.8 -- OpenAI 2.0.0 -- ReverseMarkdown 4.1.0 -- ScipBe.Common.Office.OneNote 3.0.1 -- SharpCompress 0.37.2 -- SkiaSharp.Views.WinUI 2.88.9 -- StreamJsonRpc 2.21.69 -- StyleCop.Analyzers 1.2.0-beta.556 -- System.CodeDom 9.0.8 -- System.CommandLine 2.0.0-beta4.22272.1 -- System.ComponentModel.Composition 9.0.8 -- System.Configuration.ConfigurationManager 9.0.8 -- System.Data.OleDb 9.0.8 -- System.Data.SqlClient 4.9.0 -- System.Diagnostics.EventLog 9.0.8 -- System.Diagnostics.PerformanceCounter 9.0.8 -- System.Drawing.Common 9.0.8 -- System.IO.Abstractions 22.0.13 -- System.IO.Abstractions.TestingHelpers 22.0.13 -- System.Management 9.0.8 -- System.Net.Http 4.3.4 -- System.Private.Uri 4.3.2 -- System.Reactive 6.0.1 -- System.Runtime.Caching 9.0.8 -- System.ServiceProcess.ServiceController 9.0.8 -- System.Text.Encoding.CodePages 9.0.8 -- System.Text.Json 9.0.8 -- System.Text.RegularExpressions 4.3.1 -- UnicodeInformation 2.6.0 -- UnitsNet 5.56.0 -- UTF.Unknown 2.5.1 -- WinUIEx 2.2.0 -- WPF-UI 3.0.5 -- WyHash 1.0.5 +- AdaptiveCards.ObjectModel.WinUI3 +- AdaptiveCards.Rendering.WinUI3 +- AdaptiveCards.Templating +- Appium.WebDriver +- Azure.AI.OpenAI +- CoenM.ImageSharp.ImageHash +- CommunityToolkit.Common +- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Mvvm +- CommunityToolkit.WinUI.Animations +- CommunityToolkit.WinUI.Collections +- CommunityToolkit.WinUI.Controls.Primitives +- CommunityToolkit.WinUI.Controls.Segmented +- CommunityToolkit.WinUI.Controls.SettingsControls +- CommunityToolkit.WinUI.Controls.Sizers +- CommunityToolkit.WinUI.Converters +- CommunityToolkit.WinUI.Extensions +- CommunityToolkit.WinUI.UI.Controls.DataGrid +- CommunityToolkit.WinUI.UI.Controls.Markdown +- ControlzEx +- HelixToolkit +- HelixToolkit.Core.Wpf +- hyjiacan.pinyin4net +- Interop.Microsoft.Office.Interop.OneNote +- LazyCache +- Mages +- Markdig.Signed +- MessagePack +- ModernWpfUI +- Moq +- MSTest +- NLog.Extensions.Logging +- NLog.Schema +- OpenAI +- ReverseMarkdown +- ScipBe.Common.Office.OneNote +- SharpCompress +- SkiaSharp.Views.WinUI +- StreamJsonRpc +- StyleCop.Analyzers +- UnicodeInformation +- UnitsNet +- UTF.Unknown +- WinUIEx +- WPF-UI +- WyHash \ No newline at end of file From db953bb325f014d7d1c4bf65e627fd21f1dd7552 Mon Sep 17 00:00:00 2001 From: Davide Giacometti <25966642+davidegiacometti@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:40:37 +0200 Subject: [PATCH 2/9] [Settings] Move title bar shutdown button to navigation view (#40714) ## Summary of the Pull Request Based on https://github.com/microsoft/PowerToys/pull/40260#issuecomment-3085099815 feedback, this PR remove the title bar shutdown button in favor of a menu item in the navigation view footer. - Menu item is visible only when tray icon is hidden - A confirm dialog has been added image image - Close is used in tray icon menu for closing app image image ## PR Checklist - [x] **Closes:** #40346 #40577 - [x] **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 - [x] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed - Open settings with tray icon visible: close menu is hidden - Open settings with tray icon hidden: close menu is visible - Tested close menu visibility change when tray icon option is changed - Tested cancel button of close dialog - Tested close button of dialog --------- Co-authored-by: Niels Laute --- .../Helpers/TrayIconService.cs | 2 +- .../Strings/en-us/Resources.resw | 5 ++- src/runner/Resources.resx | 8 ++-- src/runner/resource.base.h | 2 +- src/runner/runner.base.rc | Bin 2968 -> 2972 bytes src/runner/tray_icon.cpp | 6 +-- .../SettingsXAML/Views/GeneralPage.xaml | 5 ++- .../SettingsXAML/Views/GeneralPage.xaml.cs | 26 +++++------ .../SettingsXAML/Views/ShellPage.xaml | 41 ++++++------------ .../SettingsXAML/Views/ShellPage.xaml.cs | 18 +++++--- .../Settings.UI/Strings/en-us/Resources.resw | 22 ++++++++-- .../Settings.UI/ViewModels/ShellViewModel.cs | 22 +++++++--- 12 files changed, 90 insertions(+), 67 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs index 224c851ff1..442341cc5e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -92,7 +92,7 @@ internal sealed partial class TrayIconService { _popupMenu = PInvoke.CreatePopupMenu_SafeHandle(); PInvoke.InsertMenu(_popupMenu, 0, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 1, RS_.GetString("TrayMenu_Settings")); - PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Exit")); + PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Close")); } } else diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index f5810a0513..dd69fa78d4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -419,8 +419,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Settings - - Exit + + Close + Close as a verb, as in Close the application Direct diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index 3cc2f1ad36..c8eb5f25cc 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -176,10 +176,6 @@ Documentation - - Exit - Exit as a verb, as in Exit the application - Report bug @@ -193,4 +189,8 @@ Administrator + + Close + Close as a verb, as in Close the application + \ No newline at end of file diff --git a/src/runner/resource.base.h b/src/runner/resource.base.h index 027f5b4281..7037f4342d 100644 --- a/src/runner/resource.base.h +++ b/src/runner/resource.base.h @@ -15,7 +15,7 @@ #define APPICON 101 #define ID_TRAY_MENU 102 -#define ID_EXIT_MENU_COMMAND 40001 +#define ID_CLOSE_MENU_COMMAND 40001 #define ID_SETTINGS_MENU_COMMAND 40002 #define ID_ABOUT_MENU_COMMAND 40003 #define ID_REPORT_BUG_COMMAND 40004 diff --git a/src/runner/runner.base.rc b/src/runner/runner.base.rc index 10a4555db8ec5de58a2a12eb479c9547d7d7c186..367735ade45b0cdbe086a95fb5a2ce45f3cec744 100644 GIT binary patch delta 48 zcmbOsK1Y1RCT4kOh8%``hGK?P1| - + - - - - - + - - - + @@ -309,5 +289,12 @@ + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs index 11835ceeb2..57abe04119 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.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. @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Windowing; @@ -113,7 +114,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// /// Gets view model. /// - public ShellViewModel ViewModel { get; } = new ShellViewModel(); + public ShellViewModel ViewModel { get; } /// /// Gets a collection of functions that handle IPC responses. @@ -134,6 +135,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views { InitializeComponent(); + var settingsUtils = new SettingsUtils(); + ViewModel = new ShellViewModel(SettingsRepository.GetInstance(settingsUtils)); DataContext = ViewModel; ShellHandler = this; ViewModel.Initialize(shellFrame, navigationView, KeyboardAccelerators); @@ -461,17 +464,22 @@ namespace Microsoft.PowerToys.Settings.UI.Views navigationView.IsPaneOpen = !navigationView.IsPaneOpen; } - private void ExitPTItem_Tapped(object sender, RoutedEventArgs e) + private async void Close_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + await CloseDialog.ShowAsync(); + } + + private void CloseDialog_Click(ContentDialog sender, ContentDialogButtonClickEventArgs args) { const string ptTrayIconWindowClass = "PToyTrayIconWindow"; // Defined in runner/tray_icon.h - const nuint ID_EXIT_MENU_COMMAND = 40001; // Generated resource from runner/runner.base.rc + const nuint ID_CLOSE_MENU_COMMAND = 40001; // Generated resource from runner/runner.base.rc // Exit the XAML application Application.Current.Exit(); // Invoke the exit command from the tray icon IntPtr hWnd = NativeMethods.FindWindow(ptTrayIconWindowClass, ptTrayIconWindowClass); - NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_EXIT_MENU_COMMAND, 0); + NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0); } } } diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 7eede397b3..8834d22600 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -1,4 +1,4 @@ - + ## Summary of the Pull Request Updated UTF.Unknown from 2.5.1 to 2.6.0 2.5.1 is more than 3 years old and targeting old frameworks. That's fixed in 2.6.0 There are no breaking changes in 2.6.0 ## PR Checklist - [ ] **Tests:** Added/updated and all pass - waiting for workflow approval --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 71bbda5042..808d2d5ebc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -103,7 +103,7 @@ - + @@ -113,4 +113,4 @@ - \ No newline at end of file + From e0a0bbffe527f46e7fd9f3b815c686c66e03325d Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Thu, 21 Aug 2025 05:06:14 -0500 Subject: [PATCH 4/9] CmdPal: Prevent some SearchText bouncing. (#41165) This stops us from raising a PropChanged(SearchText) in DynamicListPage when we're the ones to set it. When we'd raise the PropChanged in response to a `set`, it could cause a race between CmdPal and the extension. It was totally possible that CmdPal could call ``` SearchText="foo"; SearchText="fool"; ``` and in the extension, we'd raise the PropChanged for each of those, but then have CmdPal handle those events out-of-order. This seems to entirely remove all the "jiggling" that I'd notice in the evil samples from #41158 Closes #38190 --- .../DynamicListPage.cs | 2 +- .../Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs index 2bb5ffa576..ec2602fb56 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs @@ -12,7 +12,7 @@ public abstract class DynamicListPage : ListPage, IDynamicListPage set { var oldSearch = base.SearchText; - base.SearchText = value; + SetSearchNoUpdate(value); UpdateSearchText(oldSearch, value); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs index 0eef7b44ee..16992613d8 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs @@ -105,4 +105,9 @@ public partial class ListPage : Page, IListPage { } } + + protected void SetSearchNoUpdate(string newSearchText) + { + _searchText = newSearchText; + } } From 2db1dcd10ccae81dc8d50f929ae10a2ef34c1601 Mon Sep 17 00:00:00 2001 From: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com> Date: Thu, 21 Aug 2025 03:07:14 -0700 Subject: [PATCH 5/9] Improve NuGet Dependency Version Validation via dotnet restore (#40646) ### NuGet Package Management Improvements: * This pull request includes updates to improve NuGet package management and dependency versions. ### Example problem of the new ps1 change, and fixed in this PR Updated the version of `NLog` from `5.0.4` to `5.2.8`, with the error message: error NU1605: Warning As Error: Detected package downgrade: NLog from 5.2.8 to 5.0.4. Reference the package directly from the pr oject to select a different version. Microsoft.PowerToys.Run.Plugin.History -> Wox.Plugin -> NLog.Extensions.Logging 5.3.8 -> NLog (>= 5.2.8) Microsoft.PowerToys.Run.Plugin.History -> Wox.Plugin -> NLog (>= 5.0.4) --- .github/actions/spell-check/expect.txt | 1 + .pipelines/verifyNugetPackages.ps1 | 9 +++++++++ Directory.Packages.props | 3 ++- NOTICE.md | 1 + src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj | 1 + 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c275d6725f..004d9f4269 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1118,6 +1118,7 @@ NOTSRCCOPY NOTSRCERASE notwindows NOTXORPEN +nowarn NOZORDER NPH npmjs diff --git a/.pipelines/verifyNugetPackages.ps1 b/.pipelines/verifyNugetPackages.ps1 index 54d0137121..c7cebbd383 100644 --- a/.pipelines/verifyNugetPackages.ps1 +++ b/.pipelines/verifyNugetPackages.ps1 @@ -21,4 +21,13 @@ if (-not $?) exit 1 } +# Ignore NU1503 on vcxproj files +dotnet restore $solution /nowarn:NU1503 +if ($lastExitCode -ne 0) +{ + $result = $lastExitCode + Write-Error "Error running dotnet restore, with the exit code $lastExitCode. Please verify logs on the nuget package versions." + exit $result +} + exit 0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 808d2d5ebc..a5270e7d75 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,8 @@ - + + diff --git a/NOTICE.md b/NOTICE.md index 058f0863b1..bedc11379d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1522,6 +1522,7 @@ SOFTWARE. - ModernWpfUI - Moq - MSTest +- NLog - NLog.Extensions.Logging - NLog.Schema - OpenAI diff --git a/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj b/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj index e9179e6ddf..67f7ead02c 100644 --- a/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj +++ b/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj @@ -30,6 +30,7 @@ + From 8cb2e4eaf7d6bf07b2cacc3d67df60061287edac Mon Sep 17 00:00:00 2001 From: leileizhang Date: Thu, 21 Aug 2025 18:10:30 +0800 Subject: [PATCH 6/9] refactor: Replace WiX-based registration with conditional runtime registration for Win10 context menu modules (#41275) ## Summary of the Pull Request ## Root Cause WiX-based registration creates persistent Shell Extension entries that: 1. Load DLLs even when the module is disabled 2. Cause cross-OS version conflicts (Win11 loading Win10 extensions) ## Changes Made 1. Removed static Shell Extension registration from PowerToys installer 2. Modified modules to register Shell Extensions during Runner startup ### Modified Modules: - **PowerRename** (`src/modules/powerrename/dll/dllmain.cpp`) - **NewPlus** (`src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp`) - **ImageResizer** (`src/modules/imageresizer/dll/dllmain.cpp`) - **FileLocksmith** (`src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp`) ## Known Migration Issue **Machine-level installer registry residue**: win10 with machine-level installers may have residual Shell Extension registry entries that persist with this change. ## PR Checklist - [x] Closes: #40036 - [ ] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed ## AI Summary This pull request refactors how shell extension registry keys are managed during installation and uninstallation for several PowerToys modules. The main change is moving registry key cleanup logic for context menu shell extensions (ImageResizer, FileLocksmith, PowerRename, NewPlus) from static installer definitions to new custom uninstall actions, ensuring more reliable removal and future extensibility. **Installer and Uninstall Refactoring** * Added new custom actions (`CleanImageResizerRuntimeRegistryCA`, `CleanFileLocksmithRuntimeRegistryCA`, `CleanPowerRenameRuntimeRegistryCA`, `CleanNewPlusRuntimeRegistryCA`) to programmatically clean up registry keys for each shell extension during uninstall, implemented in `CustomAction.cpp` and exported in `CustomAction.def`. [[1]](diffhunk://#diff-c502a81cdf8afa7a38f0f462709abcdbdfcc44beaa6227a1e64a26566c7e8876R1156-R1262) [[2]](diffhunk://#diff-f941d599be5fe41667eda00338af694c0f2e65709d497a66487402f13e408200R31-R34) * Registered these custom actions in `Product.wxs` and ensured they run before file removal during uninstall. [[1]](diffhunk://#diff-668b4388b55bb934d7ceccbfdd172f69257c9c607ca19cb9752d4a4940b69886R179-R190) [[2]](diffhunk://#diff-668b4388b55bb934d7ceccbfdd172f69257c9c607ca19cb9752d4a4940b69886R454-R482) **Removal of Static Registry Key Definitions** * Removed static registry key and component definitions for context menu shell extensions from their respective installer `.wxs` files (`FileLocksmith.wxs`, `ImageResizer.wxs`, `PowerRename.wxs`, `NewPlus.wxs`), relying on custom actions for cleanup instead. [[1]](diffhunk://#diff-7cf9797f8cb6609049763b3b830f6c4a7a02ba5705eb090f7e06fb9c270ca74fL17-L31) [[2]](diffhunk://#diff-7cf9797f8cb6609049763b3b830f6c4a7a02ba5705eb090f7e06fb9c270ca74fL41) [[3]](diffhunk://#diff-c6d00805ce9de0eb3f4d42874dccac17be62f36c35d57e8f863b928b5f955d3aL19-L83) [[4]](diffhunk://#diff-c6d00805ce9de0eb3f4d42874dccac17be62f36c35d57e8f863b928b5f955d3aL93) [[5]](diffhunk://#diff-d0d69eff3f2d7982679465972b7d3c46dd8006314fb28f0e3a2371e2d5ccedb0L21-L33) [[6]](diffhunk://#diff-d0d69eff3f2d7982679465972b7d3c46dd8006314fb28f0e3a2371e2d5ccedb0L43) [[7]](diffhunk://#diff-4fd109f66b896577cad2860a829617ca902b33551afaaa8840372035ade2d3f3L17-L32) [[8]](diffhunk://#diff-4fd109f66b896577cad2860a829617ca902b33551afaaa8840372035ade2d3f3L42) **Project File Update** * Added `shell_ext_registration.h` to the solution file, possibly for future shell extension registration logic. These changes improve uninstall reliability and centralize registry cleanup logic, making future maintenance and extension of shell extension registration much simpler. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PowerToys.sln | 1 + installer/PowerToysSetup/FileLocksmith.wxs | 16 -- installer/PowerToysSetup/ImageResizer.wxs | 66 ----- installer/PowerToysSetup/NewPlus.wxs | 18 +- installer/PowerToysSetup/PowerRename.wxs | 17 -- installer/PowerToysSetup/Product.wxs | 41 +++ .../CustomAction.cpp | 107 +++++++ .../CustomAction.def | 4 + src/common/utils/shell_ext_registration.h | 266 ++++++++++++++++++ .../FileLocksmithExt/FileLocksmithExt.vcxproj | 1 + .../FileLocksmithExt.vcxproj.filters | 3 + .../FileLocksmithExt/PowerToysModule.cpp | 15 +- .../FileLocksmithExt/RuntimeRegistration.h | 36 +++ .../NewShellExtensionContextMenu.vcxproj | 1 + ...wShellExtensionContextMenu.vcxproj.filters | 3 + .../RuntimeRegistration.h | 36 +++ .../powertoys_module.cpp | 20 +- .../imageresizer/dll/ImageResizerExt.vcxproj | 1 + .../dll/ImageResizerExt.vcxproj.filters | 3 + .../imageresizer/dll/RuntimeRegistration.h | 38 +++ src/modules/imageresizer/dll/dllmain.cpp | 15 +- .../powerrename/dll/PowerRenameExt.vcxproj | 1 + .../dll/PowerRenameExt.vcxproj.filters | 3 + .../powerrename/dll/RuntimeRegistration.h | 37 +++ src/modules/powerrename/dll/dllmain.cpp | 15 +- 25 files changed, 644 insertions(+), 120 deletions(-) create mode 100644 src/common/utils/shell_ext_registration.h create mode 100644 src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h create mode 100644 src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h create mode 100644 src/modules/imageresizer/dll/RuntimeRegistration.h create mode 100644 src/modules/powerrename/dll/RuntimeRegistration.h diff --git a/PowerToys.sln b/PowerToys.sln index 6033ca1481..e6ae7896fe 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -262,6 +262,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{B39DC643 src\common\utils\EventLocker.h = src\common\utils\EventLocker.h src\common\utils\EventWaiter.h = src\common\utils\EventWaiter.h src\common\utils\excluded_apps.h = src\common\utils\excluded_apps.h + src\common\utils\shell_ext_registration.h = src\common\utils\shell_ext_registration.h src\common\utils\exec.h = src\common\utils\exec.h src\common\utils\game_mode.h = src\common\utils\game_mode.h src\common\utils\gpo.h = src\common\utils\gpo.h diff --git a/installer/PowerToysSetup/FileLocksmith.wxs b/installer/PowerToysSetup/FileLocksmith.wxs index 085e60eaa7..5943ab4147 100644 --- a/installer/PowerToysSetup/FileLocksmith.wxs +++ b/installer/PowerToysSetup/FileLocksmith.wxs @@ -14,21 +14,6 @@ - - - - - - - - - - - - - - - @@ -38,7 +23,6 @@ - diff --git a/installer/PowerToysSetup/ImageResizer.wxs b/installer/PowerToysSetup/ImageResizer.wxs index 17da272014..67b5acf198 100644 --- a/installer/PowerToysSetup/ImageResizer.wxs +++ b/installer/PowerToysSetup/ImageResizer.wxs @@ -16,71 +16,6 @@ - - - - - - - - - - - - - - - - - - - - - - - @@ -90,7 +25,6 @@ - diff --git a/installer/PowerToysSetup/NewPlus.wxs b/installer/PowerToysSetup/NewPlus.wxs index 4dd1c67701..624c01fca2 100644 --- a/installer/PowerToysSetup/NewPlus.wxs +++ b/installer/PowerToysSetup/NewPlus.wxs @@ -18,19 +18,6 @@ - - - - - - - - - - - - - @@ -40,8 +27,7 @@ - - + @@ -81,7 +67,7 @@ - + diff --git a/installer/PowerToysSetup/PowerRename.wxs b/installer/PowerToysSetup/PowerRename.wxs index 1e722d9334..7aa357e207 100644 --- a/installer/PowerToysSetup/PowerRename.wxs +++ b/installer/PowerToysSetup/PowerRename.wxs @@ -14,22 +14,6 @@ - - - - - - - - - - - - - - - - @@ -39,7 +23,6 @@ - diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index f15b8a4714..77ffad8483 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -176,6 +176,18 @@ Installed AND (REMOVE="ALL") + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") @@ -437,6 +449,35 @@ Execute="deferred" BinaryKey="PTCustomActions" DllEntry="UnRegisterContextMenuPackagesCA" + /> + + + + + +#include +#include +#include + +#include "../logger/logger.h" + +namespace runtime_shell_ext +{ + struct Spec + { + // Mandatory + std::wstring clsid; // e.g. {GUID} + std::wstring sentinelKey; // e.g. Software\\Microsoft\\PowerToys\\ModuleName + std::wstring sentinelValue; // e.g. ContextMenuRegistered + std::vector dllFileCandidates; // relative filenames (pick first existing) + std::vector contextMenuHandlerKeyPaths; // full HKCU relative paths where default value = CLSID + + // Optional + std::wstring friendlyName; // if non-empty written as default under CLSID root + bool writeOptInEmptyValue = true; // write ContextMenuOptIn="" under CLSID root (legacy pattern) + bool writeThreadingModel = true; // write Apartment threading model + std::vector extraAssociationPaths; // additional key paths (DragDropHandlers etc.) default=CLSID + std::vector systemFileAssocExtensions; // e.g. .png -> Software\\Classes\\SystemFileAssociations\\.png\\ShellEx\\ContextMenuHandlers\\ + std::wstring systemFileAssocHandlerName; // e.g. ImageResizer + std::wstring representativeSystemExt; // used to decide if associations need repair (.png) + bool logRepairs = true; + }; + + namespace detail + { + // Minimal RAII wrapper for HKEY + struct unique_hkey + { + HKEY h{ nullptr }; + unique_hkey() = default; + explicit unique_hkey(HKEY handle) : h(handle) {} + ~unique_hkey() { if (h) RegCloseKey(h); } + unique_hkey(const unique_hkey&) = delete; + unique_hkey& operator=(const unique_hkey&) = delete; + unique_hkey(unique_hkey&& other) noexcept : h(other.h) { other.h = nullptr; } + unique_hkey& operator=(unique_hkey&& other) noexcept { if (this != &other) { if (h) RegCloseKey(h); h = other.h; other.h = nullptr; } return *this; } + HKEY get() const { return h; } + HKEY* put() { if (h) { RegCloseKey(h); h = nullptr; } return &h; } + }; + inline std::wstring base_dir_from_module(HMODULE h) + { + wchar_t buf[MAX_PATH]; + if (GetModuleFileNameW(h, buf, MAX_PATH)) + { + PathRemoveFileSpecW(buf); + return buf; + } + return L""; + } + + inline std::wstring pick_existing_dll(const std::wstring& base, const std::vector& candidates) + { + for (const auto& rel : candidates) + { + std::wstring full = base + L"\\" + rel; + if (GetFileAttributesW(full.c_str()) != INVALID_FILE_ATTRIBUTES) + { + return full; + } + } + if (!candidates.empty()) + { + return base + L"\\" + candidates.front(); + } + return L""; + } + + inline bool sentinel_exists(const Spec& spec) + { + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return false; + DWORD v = 0; DWORD sz = sizeof(v); + return RegQueryValueExW(key.get(), spec.sentinelValue.c_str(), nullptr, nullptr, reinterpret_cast(&v), &sz) == ERROR_SUCCESS && v == 1; + } + + inline void write_sentinel(const Spec& spec) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + DWORD one = 1; + RegSetValueExW(key.get(), spec.sentinelValue.c_str(), 0, REG_DWORD, reinterpret_cast(&one), sizeof(one)); + } + } + + inline void write_inproc_server(const Spec& spec, const std::wstring& dllPath) + { + using namespace std::string_literals; + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + std::wstring inprocKey = clsidRoot + L"\\InprocServer32"; + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, clsidRoot.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + if (!spec.friendlyName.empty()) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(spec.friendlyName.c_str()), static_cast((spec.friendlyName.size() + 1) * sizeof(wchar_t))); + } + if (spec.writeOptInEmptyValue) + { + const wchar_t* optIn = L"ContextMenuOptIn"; + const wchar_t empty = L'\0'; + RegSetValueExW(key.get(), optIn, 0, REG_SZ, reinterpret_cast(&empty), sizeof(empty)); + } + } + } + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(dllPath.c_str()), static_cast((dllPath.size() + 1) * sizeof(wchar_t))); + if (spec.writeThreadingModel) + { + const wchar_t* tm = L"Apartment"; + RegSetValueExW(key.get(), L"ThreadingModel", 0, REG_SZ, reinterpret_cast(tm), static_cast((wcslen(tm) + 1) * sizeof(wchar_t))); + } + } + } + + inline std::wstring read_inproc_server(const Spec& spec) + { + using namespace std::string_literals; + std::wstring inprocKey = L"Software\\Classes\\CLSID\\"s + spec.clsid + L"\\InprocServer32"; + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return L""; + wchar_t buf[MAX_PATH]; DWORD sz = sizeof(buf); + if (RegQueryValueExW(key.get(), nullptr, nullptr, nullptr, reinterpret_cast(buf), &sz) == ERROR_SUCCESS) + return std::wstring(buf); + return L""; + } + + inline void write_default_value_key(const std::wstring& keyPath, const std::wstring& value) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(value.c_str()), static_cast((value.size() + 1) * sizeof(wchar_t))); + } + } + + inline bool representative_association_exists(const Spec& spec) + { + using namespace std::string_literals; + if (spec.representativeSystemExt.empty() || spec.systemFileAssocHandlerName.empty()) + return true; + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + spec.representativeSystemExt + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + unique_hkey key; + return RegOpenKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, KEY_READ, key.put()) == ERROR_SUCCESS; + } + } + + inline bool EnsureRegistered(const Spec& spec, HMODULE moduleInstance) + { + using namespace std::string_literals; + auto base = detail::base_dir_from_module(moduleInstance); + auto dllPath = detail::pick_existing_dll(base, spec.dllFileCandidates); + if (dllPath.empty()) + { + Logger::error(L"Runtime registration: cannot locate dll path for CLSID {}", spec.clsid); + return false; + } + bool exists = detail::sentinel_exists(spec); + bool repaired = false; + if (exists) + { + auto current = detail::read_inproc_server(spec); + if (_wcsicmp(current.c_str(), dllPath.c_str()) != 0) + { + detail::write_inproc_server(spec, dllPath); + repaired = true; + } + if (!detail::representative_association_exists(spec)) + { + repaired = true; + } + } + if (!exists) + { + detail::write_inproc_server(spec, dllPath); + } + if (!exists || repaired) + { + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + for (const auto& path : spec.extraAssociationPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring path = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + detail::write_default_value_key(path, spec.clsid); + } + } + } + if (!exists) + { + detail::write_sentinel(spec); + Logger::info(L"Runtime registration completed for CLSID {}", spec.clsid); + } + else if (repaired && spec.logRepairs) + { + Logger::info(L"Runtime registration repaired for CLSID {}", spec.clsid); + } + return true; + } + + inline bool Unregister(const Spec& spec) + { + using namespace std::string_literals; + // Remove handler key paths + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove extra association paths (e.g., drag & drop handlers) + for (const auto& path : spec.extraAssociationPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove per-extension system file association handler keys + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + RegDeleteTreeW(HKEY_CURRENT_USER, keyPath.c_str()); + } + } + // Remove CLSID branch + if (!spec.clsid.empty()) + { + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + RegDeleteTreeW(HKEY_CURRENT_USER, clsidRoot.c_str()); + } + // Remove sentinel value (not deleting entire key to avoid disturbing other values) + if (!spec.sentinelKey.empty() && !spec.sentinelValue.empty()) + { + HKEY hKey{}; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) + { + RegDeleteValueW(hKey, spec.sentinelValue.c_str()); + RegCloseKey(hKey); + } + } + Logger::info(L"Successfully unregistered CLSID {}", spec.clsid); + return true; + } +} diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj index 0c285a8bfa..c67119808f 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj @@ -73,6 +73,7 @@ + diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters index c3b4f47ebc..49bf0e21a9 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters @@ -27,6 +27,9 @@ Header Files + + Header Files + diff --git a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp index ec755d99a3..7d188a2010 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp +++ b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp @@ -12,6 +12,7 @@ #include "FileLocksmithLib/Constants.h" #include "FileLocksmithLib/Settings.h" #include "FileLocksmithLib/Trace.h" +#include "RuntimeRegistration.h" #include "dllmain.h" #include "Generated Files/resource.h" @@ -82,12 +83,17 @@ public: { std::wstring path = get_module_folderpath(globals::instance); std::wstring packageUri = path + L"\\FileLocksmithContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(constants::nonlocalizable::ContextMenuPackageName)) { package::RegisterSparsePackage(path, packageUri); } } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::EnsureRegistered(); +#endif + } m_enabled = true; } @@ -95,6 +101,13 @@ public: virtual void disable() override { Logger::info(L"File Locksmith disabled"); + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::Unregister(); + Logger::info(L"File Locksmith context menu unregistered (Win10)"); +#endif + } m_enabled = false; } diff --git a/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h new file mode 100644 index 0000000000..4dd0d34bea --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for FileLocksmith context menu extension. +#pragma once + +#include + +namespace globals { extern HMODULE instance; } + +namespace FileLocksmithRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\FileLocksmith"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.FileLocksmithExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt", + L"Software\\Classes\\Drive\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt" }; + spec.friendlyName = L"File Locksmith Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), globals::instance); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 9ac251c8ec..90058a503e 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -123,6 +123,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters index 7d014eb00f..d8b2eaf9ea 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters @@ -84,6 +84,9 @@ Header Files + + Header Files + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h new file mode 100644 index 0000000000..91596d9e3d --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for New+ Win10 context menu. +#pragma once + +#include +#include +#include + +// Provided by dll_main.cpp +extern HMODULE module_instance_handle; + +namespace NewPlusRuntimeRegistration +{ + namespace { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\NewPlus"; + spec.sentinelValue = L"ContextMenuRegisteredWin10"; + spec.dllFileCandidates = { L"PowerToys.NewPlus.ShellExtension.win10.dll" }; + spec.contextMenuHandlerKeyPaths = { L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\NewPlusShellExtensionWin10" }; + spec.friendlyName = L"NewPlus Shell Extension Win10"; + return spec; + } + } + + inline bool EnsureRegisteredWin10() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), module_instance_handle); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp index 303f072e3b..ad94431953 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp @@ -16,6 +16,7 @@ #include "trace.h" #include "new_utilities.h" #include "Generated Files/resource.h" +#include "RuntimeRegistration.h" // Note: Settings are managed via Settings and UI Settings class NewModule : public PowertoyModuleIface @@ -93,8 +94,16 @@ public: // Log telemetry Trace::EventToggleOnOff(true); - - newplus::utilities::register_msix_package(); + if (package::IsWin11OrGreater()) + { + newplus::utilities::register_msix_package(); + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::EnsureRegisteredWin10(); +#endif + } powertoy_new_enabled = true; } @@ -141,6 +150,13 @@ private: { Trace::EventToggleOnOff(false); } + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::Unregister(); + Logger::info(L"New+ context menu unregistered (Win10)"); +#endif + } powertoy_new_enabled = false; } diff --git a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj index c76a8c6cc2..51d3bc5522 100644 --- a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj +++ b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj @@ -100,6 +100,7 @@ + diff --git a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters index 8ec4d09d40..5d58153793 100644 --- a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters +++ b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters @@ -54,6 +54,9 @@ Generated Files + + Header Files + diff --git a/src/modules/imageresizer/dll/RuntimeRegistration.h b/src/modules/imageresizer/dll/RuntimeRegistration.h new file mode 100644 index 0000000000..f9369da1c4 --- /dev/null +++ b/src/modules/imageresizer/dll/RuntimeRegistration.h @@ -0,0 +1,38 @@ +// Header-only runtime registration for ImageResizer shell extension. +#pragma once + +#include + +extern "C" IMAGE_DOS_HEADER __ImageBase; // provided by linker + +namespace ImageResizerRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{51B4D7E5-7568-4234-B4BB-47FB3C016A69}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\ImageResizer"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.ImageResizerExt.dll" }; + spec.contextMenuHandlerKeyPaths = { }; + spec.systemFileAssocHandlerName = L"ImageResizer"; + spec.systemFileAssocExtensions = { L".bmp", L".dib", L".gif", L".jfif", L".jpe", L".jpeg", L".jpg", L".jxr", L".png", L".rle", L".tif", L".tiff", L".wdp" }; + spec.representativeSystemExt = L".png"; // probe for repair + spec.extraAssociationPaths = { L"Software\\Classes\\Directory\\ShellEx\\DragDropHandlers\\ImageResizer" }; + spec.friendlyName = L"ImageResizer Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), reinterpret_cast(&__ImageBase)); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/imageresizer/dll/dllmain.cpp b/src/modules/imageresizer/dll/dllmain.cpp index 4786388f06..ef07566b7e 100644 --- a/src/modules/imageresizer/dll/dllmain.cpp +++ b/src/modules/imageresizer/dll/dllmain.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "RuntimeRegistration.h" CImageResizerExtModule _AtlModule; HINSTANCE g_hInst_imageResizer = 0; @@ -106,12 +107,17 @@ public: { std::wstring path = get_module_folderpath(g_hInst_imageResizer); std::wstring packageUri = path + L"\\ImageResizerContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(ImageResizerConstants::ModulePackageDisplayName)) { package::RegisterSparsePackage(path, packageUri); } } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + ImageResizerRuntimeRegistration::EnsureRegistered(); +#endif + } Trace::EnableImageResizer(m_enabled); } @@ -121,6 +127,13 @@ public: { m_enabled = false; Trace::EnableImageResizer(m_enabled); + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + ImageResizerRuntimeRegistration::Unregister(); + Logger::info(L"ImageResizer context menu unregistered (Win10)"); +#endif + } } // Returns if the powertoys is enabled diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index 32484c75b6..9c612a08ff 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -34,6 +34,7 @@ + diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters index ffd2177829..f4f0ffcbe5 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters @@ -36,6 +36,9 @@ Header Files + + Header Files + diff --git a/src/modules/powerrename/dll/RuntimeRegistration.h b/src/modules/powerrename/dll/RuntimeRegistration.h new file mode 100644 index 0000000000..3cb06d5876 --- /dev/null +++ b/src/modules/powerrename/dll/RuntimeRegistration.h @@ -0,0 +1,37 @@ +// Header-only runtime registration for PowerRename context menu extension. +#pragma once + +#include + +// Provided by dllmain.cpp +extern HINSTANCE g_hInst; + +namespace PowerRenameRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{0440049F-D1DC-4E46-B27B-98393D79486B}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\PowerRename"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.PowerRenameExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\PowerRenameExt", + L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\PowerRenameExt" }; + spec.friendlyName = L"PowerRename Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), g_hInst); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/powerrename/dll/dllmain.cpp b/src/modules/powerrename/dll/dllmain.cpp index 4f9c918fb6..18c612a304 100644 --- a/src/modules/powerrename/dll/dllmain.cpp +++ b/src/modules/powerrename/dll/dllmain.cpp @@ -15,6 +15,7 @@ #include #include #include +#include "RuntimeRegistration.h" #include @@ -196,12 +197,17 @@ public: { std::wstring path = get_module_folderpath(g_hInst); std::wstring packageUri = path + L"\\PowerRenameContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(PowerRenameConstants::ModulePackageDisplayName)) { package::RegisterSparsePackage(path, packageUri); } } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::EnsureRegistered(); +#endif + } } // Disable the powertoy @@ -209,6 +215,13 @@ public: { m_enabled = false; Logger::info(L"PowerRename disabled"); + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::Unregister(); + Logger::info(L"PowerRename context menu unregistered (Win10)"); +#endif + } } // Returns if the powertoy is enabled From 1a798e03cd9d829b609bc8027f757fc2eab69306 Mon Sep 17 00:00:00 2001 From: Yu Leng <42196638+moooyo@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:24:20 +0800 Subject: [PATCH 7/9] [CmdPal][UnitTests] Add/Migrate unit test for WebSearch and Shell extension (#41272) ## Summary of the Pull Request ## PR Checklist - [x] Closes: #41241 #41242 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Co-authored-by: Yu Leng --- PowerToys.sln | 22 +++ ...icrosoft.CmdPal.Ext.Shell.UnitTests.csproj | 23 +++ .../QueryTests.cs | 146 ++++++++++++++++++ .../Settings.cs | 49 ++++++ .../ShellCommandProviderTests.cs | 51 ++++++ ...soft.CmdPal.Ext.WebSearch.UnitTests.csproj | 23 +++ .../MockSettingsInterface.cs | 73 +++++++++ .../QueryTests.cs | 141 +++++++++++++++++ .../WebSearchCommandProviderTests.cs | 56 +++++++ .../Commands/ExecuteItem.cs | 4 +- .../Helpers/ISettingsInterface.cs | 20 +++ .../Helpers/SettingsManager.cs | 2 +- .../Helpers/ShellListPageHelpers.cs | 4 +- .../Pages/ShellListPage.cs | 2 +- .../Properties/AssemblyInfo.cs | 7 + .../Commands/SearchWebCommand.cs | 4 +- .../Helpers/ISettingsInterface.cs | 19 +++ .../Helpers/SettingsManager.cs | 2 +- .../Pages/WebSearchListPage.cs | 4 +- .../Properties/AssemblyInfo.cs | 7 + 20 files changed, 648 insertions(+), 11 deletions(-) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs diff --git a/PowerToys.sln b/PowerToys.sln index e6ae7896fe..2b6d42305f 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -793,6 +793,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Apps.U EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Bookmarks.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj", "{E816D7B3-4688-4ECB-97CC-3D8E798F3832}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSearch.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.WebSearch.UnitTests\Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj", "{E816D7B2-4688-4ECB-97CC-3D8E798F3831}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -2871,6 +2875,22 @@ Global {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|x64.ActiveCfg = Release|x64 {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|x64.Build.0 = Release|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|x64.Build.0 = Debug|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|x64.ActiveCfg = Release|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|x64.Build.0 = Release|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|x64.Build.0 = Debug|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3184,6 +3204,8 @@ Global {00D8659C-2068-40B6-8B86-759CD6284BBB} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B1-4688-4ECB-97CC-3D8E798F3830} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj new file mode 100644 index 0000000000..74aeb04b39 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.Shell.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..bf9fcac406 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -0,0 +1,146 @@ +// 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.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + private static Mock CreateMockHistoryService(IList historyItems = null) + { + var mockHistoryService = new Mock(); + var history = historyItems ?? new List(); + + mockHistoryService.Setup(x => x.GetRunHistory()) + .Returns(() => history.ToList().AsReadOnly()); + + mockHistoryService.Setup(x => x.AddRunHistoryItem(It.IsAny())) + .Callback(item => + { + if (!string.IsNullOrWhiteSpace(item)) + { + history.Remove(item); + history.Insert(0, item); + } + }); + + mockHistoryService.Setup(x => x.ClearRunHistory()) + .Callback(() => history.Clear()); + + return mockHistoryService; + } + + private static Mock CreateMockHistoryServiceWithCommonCommands() + { + var commonCommands = new List + { + "ping google.com", + "ipconfig /all", + "curl https://api.github.com", + "dir", + "cd ..", + "git status", + "npm install", + "python --version", + }; + + return CreateMockHistoryService(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + + // Act + settings.AddCmdHistory("test-command"); + + // Assert + Assert.AreEqual(1, settings.Count["test-command"]); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + public async Task QueryWithoutHistoryCommand(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistory = CreateMockHistoryService(); + + var pages = new ShellListPage(settings, mockHistory.Object); + + pages.UpdateSearchText(string.Empty, command); + + // wait for about 1s. + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + Assert.AreEqual(1, commandList.Length); + + var executeCommand = commandList.FirstOrDefault(); + Assert.IsNotNull(executeCommand); + Assert.IsNotNull(executeCommand.Icon); + Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}"); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + public async Task QueryWithHistoryCommands(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object); + + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + + var expectedCommand = commandList.FirstOrDefault(); + Assert.IsNotNull(expectedCommand); + Assert.IsNotNull(expectedCommand.Icon); + Assert.IsTrue(expectedCommand.Title.Contains(exeName), $"expect ${exeName} but got ${expectedCommand.Title}"); + } + + [TestMethod] + public async Task EmptyQueryWithHistoryCommands() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object); + + pages.UpdateSearchText("abcdefg", string.Empty); + + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs new file mode 100644 index 0000000000..953f252be8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs @@ -0,0 +1,49 @@ +// 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.Generic; +using Microsoft.CmdPal.Ext.Shell.Helpers; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool leaveShellOpen; + private readonly string shellCommandExecution; + private readonly bool runAsAdministrator; + private readonly Dictionary count; + + public Settings( + bool leaveShellOpen = false, + string shellCommandExecution = "0", + bool runAsAdministrator = false, + Dictionary count = null) + { + this.leaveShellOpen = leaveShellOpen; + this.shellCommandExecution = shellCommandExecution; + this.runAsAdministrator = runAsAdministrator; + this.count = count ?? new Dictionary(); + } + + public bool LeaveShellOpen => leaveShellOpen; + + public string ShellCommandExecution => shellCommandExecution; + + public bool RunAsAdministrator => runAsAdministrator; + + public Dictionary Count => count; + + public void AddCmdHistory(string cmdName) + { + count[cmdName] = count.TryGetValue(cmdName, out var currentCount) ? currentCount + 1 : 1; + } + + public static Settings CreateDefaultSettings() => new Settings(); + + public static Settings CreateLeaveShellOpenSettings() => new Settings(leaveShellOpen: true); + + public static Settings CreatePowerShellSettings() => new Settings(shellCommandExecution: "1"); + + public static Settings CreateAdministratorSettings() => new Settings(runAsAdministrator: true); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs new file mode 100644 index 0000000000..42fb0900a4 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs @@ -0,0 +1,51 @@ +// 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.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class ShellCommandProviderTests +{ + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj new file mode 100644 index 0000000000..d819beb7c7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.WebSearch.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs new file mode 100644 index 0000000000..853360674f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs @@ -0,0 +1,73 @@ +// 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.Generic; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockSettingsInterface : ISettingsInterface +{ + private readonly List _historyItems; + + public bool GlobalIfURI { get; set; } + + public string ShowHistory { get; set; } + + public MockSettingsInterface(string showHistory = "none", bool globalIfUri = true, List mockHistory = null) + { + _historyItems = mockHistory ?? new List(); + GlobalIfURI = globalIfUri; + ShowHistory = showHistory; + } + + public List LoadHistory() + { + var listItems = new List(); + foreach (var historyItem in _historyItems) + { + listItems.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, this)) + { + Title = historyItem.SearchString, + Subtitle = historyItem.Timestamp.ToString("g", System.Globalization.CultureInfo.InvariantCulture), + }); + } + + listItems.Reverse(); + return listItems; + } + + public void SaveHistory(HistoryItem historyItem) + { + if (historyItem is null) + { + return; + } + + _historyItems.Add(historyItem); + + // Simulate the same logic as SettingsManager + if (int.TryParse(ShowHistory, out var maxHistoryItems) && maxHistoryItems > 0) + { + while (_historyItems.Count > maxHistoryItems) + { + _historyItems.RemoveAt(0); // Remove the oldest item + } + } + } + + // Helper method for testing + public void ClearHistory() + { + _historyItems.Clear(); + } + + // Helper method for testing + public int GetHistoryCount() + { + return _historyItems.Count; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..64a5366a61 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs @@ -0,0 +1,141 @@ +// 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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Pages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + [DataRow("microsoft")] + [DataRow("windows")] + public async Task SearchInWebSearchPage(string query) + { + // Setup + var settings = new MockSettingsInterface(); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText(string.Empty, query); + await Task.Delay(1000); + + var listItem = page.GetItems(); + Assert.IsNotNull(listItem); + Assert.AreEqual(1, listItem.Length); + + var expectedItem = listItem.FirstOrDefault(); + + Assert.IsNotNull(expectedItem); + Assert.IsTrue(expectedItem.Subtitle.Contains("Search the web in"), $"Expected \"search the web in chrome/edge\" but got {expectedItem.Subtitle}"); + Assert.AreEqual(query, expectedItem.Title); + } + + [TestMethod] + public async Task LoadHistoryReturnsExpectedItems() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "5"); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual(2, listItem.Length); + + foreach (var item in listItem) + { + Assert.IsNotNull(item); + Assert.IsNotEmpty(item.Title); + Assert.IsNotEmpty(item.Subtitle); + } + } + + [TestMethod] + public async Task LoadHistoryMoreThanLimitation() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "5"); + + var page = new WebSearchListPage(settings); + + mockHistoryItems.Add(new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture))); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(5, listItem.Length); + } + + [TestMethod] + public async Task LoadHistoryWithDisableSetting() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "None"); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(0, listItem.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs new file mode 100644 index 0000000000..c141d28d6e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs @@ -0,0 +1,56 @@ +// 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.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class WebSearchCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.AreEqual("WebSearch", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs index 4ca772dc1e..f41f5e0ab7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs @@ -14,14 +14,14 @@ namespace Microsoft.CmdPal.Ext.Shell.Commands; internal sealed partial class ExecuteItem : InvokableCommand { - private readonly SettingsManager _settings; + private readonly ISettingsInterface _settings; private readonly RunAsType _runas; public string Cmd { get; internal set; } = string.Empty; private static readonly char[] Separator = [' ']; - public ExecuteItem(string cmd, SettingsManager settings, RunAsType type = RunAsType.None) + public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None) { if (type == RunAsType.Administrator) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..4a03d55d3d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs @@ -0,0 +1,20 @@ +// 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.Generic; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +public interface ISettingsInterface +{ + public bool LeaveShellOpen { get; } + + public string ShellCommandExecution { get; } + + public bool RunAsAdministrator { get; } + + public Dictionary Count { get; } + + public void AddCmdHistory(string cmdName); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs index a39e723338..9d58bc939d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs @@ -9,7 +9,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "shell"; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index 6a545c7225..eed1d71e49 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -17,9 +17,9 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers; public class ShellListPageHelpers { private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times); - private readonly SettingsManager _settings; + private readonly ISettingsInterface _settings; - public ShellListPageHelpers(SettingsManager settings) + public ShellListPageHelpers(ISettingsInterface settings) { _settings = settings; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index 4b99477d2a..fde17ba14c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -35,7 +35,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private bool _loadedInitialHistory; - public ShellListPage(SettingsManager settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) + public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) { Icon = Icons.RunV2Icon; Id = "com.microsoft.cmdpal.shell"; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..e308b0e6cc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Shell.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs index 1004f151a3..ad9b43f859 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -13,11 +13,11 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class SearchWebCommand : InvokableCommand { - private readonly SettingsManager _settingsManager; + private readonly ISettingsInterface _settingsManager; public string Arguments { get; internal set; } = string.Empty; - internal SearchWebCommand(string arguments, SettingsManager settingsManager) + internal SearchWebCommand(string arguments, ISettingsInterface settingsManager) { Arguments = arguments; BrowserInfo.UpdateIfTimePassed(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..c9a5723c15 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs @@ -0,0 +1,19 @@ +// 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.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +public interface ISettingsInterface +{ + public bool GlobalIfURI { get; } + + public string ShowHistory { get; } + + public List LoadHistory(); + + public void SaveHistory(HistoryItem historyItem); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs index 300cb105fb..31bbdae697 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs @@ -14,7 +14,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private readonly string _historyPath; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs index faf65cd973..6814e83ddb 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -20,12 +20,12 @@ internal sealed partial class WebSearchListPage : DynamicListPage { private readonly string _iconPath = string.Empty; private readonly List? _historyItems; - private readonly SettingsManager _settingsManager; + private readonly ISettingsInterface _settingsManager; private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private List _allItems; - public WebSearchListPage(SettingsManager settingsManager) + public WebSearchListPage(ISettingsInterface settingsManager) { Name = Resources.command_item_title; Title = Resources.command_item_title; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..b66aababe0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.WebSearch.UnitTests")] From 69dc1d5e182782a05cb235f1eb58c2d70922e33a Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Thu, 21 Aug 2025 05:40:09 -0500 Subject: [PATCH 8/9] CmdPal: Filters for DynamicListPage? Yes, please. (#40783) Closes: #40382 ## To-do list - [x] Add support for "single-select" filters to DynamicListPage - [x] Filters can contain icons - [x] Filter list can contain separators - [x] Update Windows Services built-in extension to support filtering by all, started, stopped, and pending services - [x] Update SampleExtension dynamic list sample to filter. ## Example of filters in use ```C# internal sealed partial class ServicesListPage : DynamicListPage { public ServicesListPage() { Icon = Icons.ServicesIcon; Name = "Windows Services"; var filters = new ServiceFilters(); filters.PropChanged += Filters_PropChanged; Filters = filters; } private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(); public override IListItem[] GetItems() { // ServiceHelper.Search knows how to filter based on the CurrentFilterIds provided var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterIds).ToArray(); return items; } } public partial class ServiceFilters : Filters { public ServiceFilters() { // This would be a default selection. Not providing this will cause the filter // control to display the "Filter" placeholder text. CurrentFilterIds = ["all"]; } public override IFilterItem[] GetFilters() { return [ new Filter() { Id = "all", Name = "All Services" }, new Separator(), new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon }, new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon }, new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon }, ]; } } ``` ## Current example of behavior https://github.com/user-attachments/assets/2e325763-ad3a-4445-bbe2-a840df08d0b3 --------- Co-authored-by: Mike Griese --- .../CommandItemViewModel.cs | 4 +- .../ContentPageViewModel.cs | 4 +- .../FilterItemViewModel.cs | 57 ++++++ .../FiltersViewModel.cs | 81 ++++++++ .../IContextItemViewModel.cs | 5 - .../IFilterItemViewModel.cs | 9 + .../ListViewModel.cs | 38 +++- .../PageViewModel.cs | 8 +- ...ItemViewModel.cs => SeparatorViewModel.cs} | 6 +- .../Controls/ContextMenu.xaml | 2 +- .../Controls/ContextMenu.xaml.cs | 2 +- .../Controls/FiltersDropDown.xaml | 89 +++++++++ .../Controls/FiltersDropDown.xaml.cs | 189 ++++++++++++++++++ .../Controls/SearchBar.xaml.cs | 10 +- .../Converters/ContextItemTemplateSelector.cs | 2 +- .../Converters/FilterTemplateSelector.cs | 36 ++++ .../Microsoft.CmdPal.UI/Pages/ShellPage.xaml | 13 ++ .../Microsoft.CmdPal.Ext.Apps/AppListItem.cs | 2 +- .../Helpers/ServiceHelper.cs | 17 +- .../Pages/ServiceFilters.cs | 26 +++ .../Pages/ServicesListPage.cs | 8 +- .../Pages/SampleDynamicListPage.cs | 40 +++- .../Pages/SampleListPage.cs | 2 +- .../Filters.cs | 23 +++ .../{SeparatorContextItem.cs => Separator.cs} | 2 +- .../Microsoft.CommandPalette.Extensions.idl | 4 +- 26 files changed, 646 insertions(+), 33 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs rename src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/{SeparatorContextItemViewModel.cs => SeparatorViewModel.cs} (73%) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs rename src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/{SeparatorContextItem.cs => Separator.cs} (76%) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index 4f589a4e2f..22dad16504 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -189,7 +189,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } else { - return new SeparatorContextItemViewModel() as IContextItemViewModel; + return new SeparatorViewModel() as IContextItemViewModel; } }) .ToList(); @@ -350,7 +350,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } else { - return new SeparatorContextItemViewModel() as IContextItemViewModel; + return new SeparatorViewModel() as IContextItemViewModel; } }) .ToList(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs index 0c0f7c7c12..9665908474 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs @@ -119,7 +119,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC } else { - return new SeparatorContextItemViewModel(); + return new SeparatorViewModel(); } }) .ToList(); @@ -178,7 +178,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC } else { - return new SeparatorContextItemViewModel(); + return new SeparatorViewModel(); } }) .ToList(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs new file mode 100644 index 0000000000..78fdb26286 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs @@ -0,0 +1,57 @@ +// 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.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FilterItemViewModel : ExtensionObjectViewModel, IFilterItemViewModel +{ + private ExtensionObject _model; + + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public IconInfoViewModel Icon { get; set; } = new(null); + + internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; + + protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized); + + public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error); + + public FilterItemViewModel(IFilter filter, WeakReference context) + : base(context) + { + _model = new(filter); + } + + public override void InitializeProperties() + { + if (IsInitialized) + { + return; + } + + var filter = _model.Unsafe; + if (filter == null) + { + return; // throw? + } + + Id = filter.Id; + Name = filter.Name; + Icon = new(filter.Icon); + if (Icon is not null) + { + Icon.InitializeProperties(); + } + + UpdateProperty(nameof(Id)); + UpdateProperty(nameof(Name)); + UpdateProperty(nameof(Icon)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs new file mode 100644 index 0000000000..511581558c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs @@ -0,0 +1,81 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FiltersViewModel : ExtensionObjectViewModel +{ + private readonly ExtensionObject _filtersModel = new(null); + + [ObservableProperty] + public partial string CurrentFilterId { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShouldShowFilters))] + public partial IFilterItemViewModel[] Filters { get; set; } = []; + + public bool ShouldShowFilters => Filters.Length > 0; + + public FiltersViewModel(ExtensionObject filters, WeakReference context) + : base(context) + { + _filtersModel = filters; + } + + public override void InitializeProperties() + { + try + { + if (_filtersModel.Unsafe is not null) + { + var filters = _filtersModel.Unsafe.GetFilters(); + Filters = filters.Select(filter => + { + var filterItem = filter as IFilter; + if (filterItem != null) + { + var filterVM = new FilterItemViewModel(filterItem!, PageContext); + filterVM.InitializeProperties(); + + return filterVM; + } + else + { + return new SeparatorViewModel(); + } + }).ToArray(); + + CurrentFilterId = _filtersModel.Unsafe.CurrentFilterId; + + return; + } + } + catch (Exception ex) + { + ShowException(ex, _filtersModel.Unsafe?.GetType().Name); + } + + Filters = []; + CurrentFilterId = string.Empty; + } + + public override void SafeCleanup() + { + base.SafeCleanup(); + + foreach (var filter in Filters) + { + if (filter is FilterItemViewModel filterVM) + { + filterVM.SafeCleanup(); + } + } + + Filters = []; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs index cad1af9d4d..a8f65b2634 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs @@ -2,12 +2,7 @@ // 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 System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.CmdPal.Core.ViewModels; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs new file mode 100644 index 0000000000..fb324bb42f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.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.Core.ViewModels; + +public interface IFilterItemViewModel +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index 10fc9445ad..f89b2a5906 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -26,6 +26,8 @@ public partial class ListViewModel : PageViewModel, IDisposable [ObservableProperty] public partial ObservableCollection FilteredItems { get; set; } = []; + public FiltersViewModel? Filters { get; set; } + private ObservableCollection Items { get; set; } = []; private readonly ExtensionObject _model; @@ -86,7 +88,7 @@ public partial class ListViewModel : PageViewModel, IDisposable // 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) => FetchItems(); - protected override void OnFilterUpdated(string filter) + protected override void OnSearchTextBoxUpdated(string searchTextBox) { //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... @@ -104,7 +106,7 @@ public partial class ListViewModel : PageViewModel, IDisposable { if (_model.Unsafe is IDynamicListPage dynamic) { - dynamic.SearchText = filter; + dynamic.SearchText = searchTextBox; } } catch (Exception ex) @@ -127,6 +129,26 @@ public partial class ListViewModel : PageViewModel, IDisposable } } + public void UpdateCurrentFilter(string currentFilterId) + { + // We're getting called on the UI thread. + // Hop off to a BG thread to update the extension. + _ = Task.Run(() => + { + try + { + if (_model.Unsafe is IListPage listPage) + { + listPage.Filters?.CurrentFilterId = currentFilterId; + } + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + } + }); + } + //// Run on background thread, from InitializeAsync or Model_ItemsChanged private void FetchItems() { @@ -305,7 +327,7 @@ public partial class ListViewModel : PageViewModel, IDisposable /// Apply our current filter text to the list of items, and update /// FilteredItems to match the results. /// - private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter)); + private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox)); /// /// Helper to generate a weighting for a given list item, based on title, @@ -507,6 +529,10 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); + Filters = new(new(model.Filters), PageContext); + Filters.InitializeProperties(); + UpdateProperty(nameof(Filters)); + FetchItems(); model.ItemsChanged += Model_ItemsChanged; } @@ -578,6 +604,10 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); break; + case nameof(Filters): + Filters = new(new(model.Filters), PageContext); + Filters.InitializeProperties(); + break; case nameof(IsLoading): UpdateEmptyContent(); break; @@ -641,6 +671,8 @@ public partial class ListViewModel : PageViewModel, IDisposable FilteredItems.Clear(); } + Filters?.SafeCleanup(); + var model = _model.Unsafe; if (model is not null) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 046c9fae93..5c445615be 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -32,7 +32,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext // This is set from the SearchBar [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSuggestion))] - public partial string Filter { get; set; } = string.Empty; + public partial string SearchTextBox { get; set; } = string.Empty; [ObservableProperty] public virtual partial string PlaceholderText { get; private set; } = "Type here to search..."; @@ -41,7 +41,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext [NotifyPropertyChangedFor(nameof(ShowSuggestion))] public virtual partial string TextToSuggest { get; protected set; } = string.Empty; - public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter; + public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox; [ObservableProperty] public partial AppExtensionHost ExtensionHost { get; private set; } @@ -167,9 +167,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext } } - partial void OnFilterChanged(string oldValue, string newValue) => OnFilterUpdated(newValue); + partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue); - protected virtual void OnFilterUpdated(string filter) + protected virtual void OnSearchTextBoxUpdated(string searchTextBox) { // The base page has no notion of data, so we do nothing here... // subclasses should override. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs similarity index 73% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs rename to src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs index 8d896bd341..a1c4696b35 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs @@ -9,6 +9,10 @@ using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Core.ViewModels; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -public partial class SeparatorContextItemViewModel() : IContextItemViewModel, ISeparatorContextItem +public partial class SeparatorViewModel() : + IContextItemViewModel, + IFilterItemViewModel, + ISeparatorContextItem, + ISeparatorFilterItem { } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index f3c4e5413e..e23fb815b0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -108,7 +108,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs new file mode 100644 index 0000000000..b51376dfa3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs @@ -0,0 +1,189 @@ +// 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.Core.ViewModels; +using Microsoft.CmdPal.UI.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class FiltersDropDown : UserControl, + ICurrentPageAware +{ + public PageViewModel? CurrentPageViewModel + { + get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); + set => SetValue(CurrentPageViewModelProperty, value); + } + + public static readonly DependencyProperty CurrentPageViewModelProperty = + DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnCurrentPageViewModelChanged)); + + private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var @this = (FiltersDropDown)d; + + if (@this != null + && e.OldValue is PageViewModel old) + { + old.PropertyChanged -= @this.Page_PropertyChanged; + } + + // If this new page does not implement ListViewModel or if + // it doesn't contain Filters, we need to clear any filters + // that may have been set. + if (@this != null) + { + if (e.NewValue is ListViewModel listViewModel) + { + @this.ViewModel = listViewModel.Filters; + } + else + { + @this.ViewModel = null; + } + } + + if (@this != null + && e.NewValue is PageViewModel page) + { + page.PropertyChanged += @this.Page_PropertyChanged; + } + } + + public FiltersViewModel? ViewModel + { + get => (FiltersViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null)); + + public FiltersDropDown() + { + this.InitializeComponent(); + } + + // Used to handle the case when a ListPage's `Filters` may have changed + private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var property = e.PropertyName; + + if (CurrentPageViewModel is ListViewModel list) + { + if (property == nameof(ListViewModel.Filters)) + { + ViewModel = list.Filters; + } + } + } + + private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (CurrentPageViewModel is ListViewModel listViewModel && + FiltersComboBox.SelectedItem is FilterItemViewModel filterItem) + { + listViewModel.UpdateCurrentFilter(filterItem.Id); + } + } + + private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + NavigateUp(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + NavigateDown(); + + e.Handled = true; + } + } + + private void NavigateUp() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex > 0) + { + newIndex--; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + + if (newIndex < 0) + { + newIndex = FiltersComboBox.Items.Count - 1; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + } + } + else + { + newIndex = FiltersComboBox.Items.Count - 1; + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private void NavigateDown() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1) + { + newIndex = 0; + } + else + { + newIndex++; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + + if (newIndex >= FiltersComboBox.Items.Count) + { + newIndex = 0; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + } + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private bool IsSeparator(object item) + { + return item is SeparatorViewModel; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 7b34594b46..a5f02d76cb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -62,7 +62,7 @@ public sealed partial class SearchBar : UserControl, { // TODO: In some cases we probably want commands to clear a filter // somewhere in the process, so we need to figure out when that is. - @this.FilterBox.Text = page.Filter; + @this.FilterBox.Text = page.SearchTextBox; @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); page.PropertyChanged += @this.Page_PropertyChanged; @@ -87,7 +87,7 @@ public sealed partial class SearchBar : UserControl, if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = string.Empty; + CurrentPageViewModel.SearchTextBox = string.Empty; } })); } @@ -145,7 +145,7 @@ public sealed partial class SearchBar : UserControl, // hack TODO GH #245 if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } @@ -156,7 +156,7 @@ public sealed partial class SearchBar : UserControl, // hack TODO GH #245 if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } } @@ -320,7 +320,7 @@ public sealed partial class SearchBar : UserControl, // Actually plumb Filtering to the view model if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs index 3ab4dd1c0b..09fa902a4b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs @@ -28,7 +28,7 @@ internal sealed partial class ContextItemTemplateSelector : DataTemplateSelector { li.IsEnabled = true; - if (item is SeparatorContextItemViewModel) + if (item is SeparatorViewModel) { li.IsEnabled = false; li.AllowFocusWhenDisabled = false; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs new file mode 100644 index 0000000000..2d12c82b28 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs @@ -0,0 +1,36 @@ +// 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.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class FilterTemplateSelector : DataTemplateSelector +{ + public DataTemplate? Default { get; set; } + + public DataTemplate? Separator { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) + { + DataTemplate? dataTemplate = Default; + + if (dependencyObject is ComboBoxItem comboBoxItem) + { + comboBoxItem.IsEnabled = true; + + if (item is SeparatorViewModel) + { + comboBoxItem.IsEnabled = false; + comboBoxItem.AllowFocusWhenDisabled = false; + comboBoxItem.AllowFocusOnInteraction = false; + dataTemplate = Separator; + } + } + + return dataTemplate; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index 8f7c6ac7bd..dbb3818518 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -176,6 +176,7 @@ + @@ -320,6 +321,18 @@ + + + + + + + + + (); newCommands.AddRange(commands); - newCommands.Add(new SeparatorContextItem()); + newCommands.Add(new Separator()); // 0x50 = P // Full key chord would be Ctrl+P diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs index 6e874b1581..feb7aac9c0 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Helpers; public static class ServiceHelper { - public static IEnumerable Search(string search) + public static IEnumerable Search(string search, string filterId) { var services = ServiceController.GetServices().OrderBy(s => s.DisplayName); IEnumerable serviceList = []; @@ -44,6 +44,21 @@ public static class ServiceHelper serviceList = servicesStartsWith.Concat(servicesContains); } + switch (filterId) + { + case "running": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Running); + break; + case "stopped": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Stopped); + break; + case "paused": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Paused); + break; + case "all": + break; + } + var result = serviceList.Select(s => { var serviceResult = ServiceResult.CreateServiceController(s); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs new file mode 100644 index 0000000000..179315b0c3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs @@ -0,0 +1,26 @@ +// 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.Ext.WindowsServices; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class ServiceFilters : Filters +{ + public ServiceFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = "All Services" }, + new Separator(), + new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon }, + new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon }, + new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs index 1f361b6b10..4892a1594b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs @@ -16,13 +16,19 @@ internal sealed partial class ServicesListPage : DynamicListPage { Icon = Icons.ServicesIcon; Name = "Windows Services"; + + var filters = new ServiceFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; } + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); public override IListItem[] GetItems() { - var items = ServiceHelper.Search(SearchText).ToArray(); + var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterId).ToArray(); return items; } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs index c284c7d784..b0044379be 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs @@ -2,6 +2,7 @@ // 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 System.Linq; using System.Runtime.InteropServices.WindowsRuntime; using Microsoft.CommandPalette.Extensions; @@ -16,9 +17,14 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage Icon = new IconInfo(string.Empty); Name = "Dynamic List"; IsLoading = true; + var filters = new SampleFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; } - public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(newSearch.Length); + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(); public override IListItem[] GetItems() { @@ -28,6 +34,23 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }]; } + if (!string.IsNullOrEmpty(Filters.CurrentFilterId)) + { + switch (Filters.CurrentFilterId) + { + case "mod2": + items = items.Where((item, index) => (index + 1) % 2 == 0).ToArray(); + break; + case "mod3": + items = items.Where((item, index) => (index + 1) % 3 == 0).ToArray(); + break; + case "all": + default: + // No filtering + break; + } + } + if (items.Length > 0) { items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box"; @@ -36,3 +59,18 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage return items; } } + +#pragma warning disable SA1402 // File may only contain a single type +public partial class SampleFilters : Filters +#pragma warning restore SA1402 // File may only contain a single type +{ + public override IFilterItem[] GetFilters() + { + return + [ + new Filter() { Id = "all", Name = "All" }, + new Filter() { Id = "mod2", Name = "Every 2nd", Icon = new IconInfo("2") }, + new Filter() { Id = "mod3", Name = "Every 3rd", Icon = new IconInfo("3") }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs index 2c3fabf5c6..4d0cd8b6f4 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -81,7 +81,7 @@ internal sealed partial class SampleListPage : ListPage Title = "I'm a second command", RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), }, - new SeparatorContextItem(), + new Separator(), new CommandContextItem( new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs new file mode 100644 index 0000000000..2379382d1e --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs @@ -0,0 +1,23 @@ +// 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.CommandPalette.Extensions.Toolkit; + +public abstract partial class Filters : BaseObservable, IFilters +{ + public string CurrentFilterId + { + get => field; + set + { + field = value; + OnPropertyChanged(nameof(CurrentFilterId)); + } + } + + = string.Empty; + + // This method should be overridden in derived classes to provide the actual filters. + public abstract IFilterItem[] GetFilters(); +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs similarity index 76% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs index c851634f59..d47eff6b22 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SeparatorContextItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs @@ -4,6 +4,6 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class SeparatorContextItem : ISeparatorContextItem +public partial class Separator : ISeparatorContextItem, ISeparatorFilterItem { } 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 3105b0647d..51ddbdc572 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 @@ -122,7 +122,7 @@ namespace Microsoft.CommandPalette.Extensions interface ISeparatorFilterItem requires IFilterItem {} [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] - interface IFilter requires IFilterItem { + interface IFilter requires INotifyPropChanged, IFilterItem { String Id { get; }; String Name { get; }; IIconInfo Icon { get; }; @@ -131,7 +131,7 @@ namespace Microsoft.CommandPalette.Extensions [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface IFilters { String CurrentFilterId { get; set; }; - IFilterItem[] Filters(); + IFilterItem[] GetFilters(); } struct Color From da572c6c400c6aca25063a057a155848e50e9b3e Mon Sep 17 00:00:00 2001 From: leileizhang Date: Thu, 21 Aug 2025 18:53:30 +0800 Subject: [PATCH 9/9] [UI tests] Add accessibility IDs to Command Palette UI components for improved UI testing (#41295) ## Summary of the Pull Request Finding elements by name is often unstable; adding an accessibility ID is more reliable for UI tests. ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../Controls/CommandBar.xaml | 3 +++ .../Controls/SearchBar.xaml | 1 + .../CommandPaletteTestBase.cs | 23 +++++++------------ .../Microsoft.CmdPal.UITests/IndexerTests.cs | 4 ++-- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index aa14b0878a..aba9ed477d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -165,6 +165,7 @@ x:Name="PrimaryButton" Padding="6,4,4,4" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="PrimaryCommandButton" AutomationProperties.Name="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" Background="Transparent" Click="PrimaryButton_Clicked" @@ -184,6 +185,7 @@ x:Name="SecondaryButton" Padding="6,4,4,4" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="SecondaryCommandButton" AutomationProperties.Name="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" Click="SecondaryButton_Clicked" Style="{StaticResource SubtleButtonStyle}" @@ -207,6 +209,7 @@ x:Name="MoreCommandsButton" x:Uid="MoreCommandsButton" Padding="6,4,4,4" + AutomationProperties.AutomationId="MoreContextMenuButton" Click="MoreCommandsButton_Clicked" Style="{StaticResource SubtleButtonStyle}" ToolTipService.ToolTip="Ctrl+K" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml index 379ea6b03d..80eb1a3ad6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml @@ -22,6 +22,7 @@ MinHeight="32" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch" + AutomationProperties.AutomationId="MainSearchBox" KeyDown="FilterBox_KeyDown" PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}" PreviewKeyDown="FilterBox_PreviewKeyDown" diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs index ab7dac1a3a..a839c3e999 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs @@ -19,29 +19,22 @@ public class CommandPaletteTestBase : UITestBase { } - protected void SetSearchBox(string text) - { - Assert.AreEqual(this.Find("Type here to search...").SetText(text, true).Text, text); - } + protected void SetSearchBox(string text) => SetSearchBoxText(text); - protected void SetFilesExtensionSearchBox(string text) - { - Assert.AreEqual(this.Find("Search for files and folders...").SetText(text, true).Text, text); - } + protected void SetFilesExtensionSearchBox(string text) => SetSearchBoxText(text); - protected void SetCalculatorExtensionSearchBox(string text) - { - Assert.AreEqual(this.Find("Type an equation...").SetText(text, true).Text, text); - } + protected void SetCalculatorExtensionSearchBox(string text) => SetSearchBoxText(text); - protected void SetTimeAndDaterExtensionSearchBox(string text) + protected void SetTimeAndDaterExtensionSearchBox(string text) => SetSearchBoxText(text); + + private void SetSearchBoxText(string text) { - Assert.AreEqual(this.Find("Search values or type a custom time stamp...").SetText(text, true).Text, text); + Assert.AreEqual(this.Find(By.AccessibilityId("MainSearchBox")).SetText(text, true).Text, text); } protected void OpenContextMenu() { - var contextMenuButton = this.Find