diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index db96384da6..e539d0ae1c 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1120,6 +1120,7 @@ NOTSRCCOPY NOTSRCERASE notwindows NOTXORPEN +nowarn NOZORDER NPH npmjs 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/.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 71bbda5042..a5270e7d75 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,8 @@ - + + @@ -103,7 +104,7 @@ - + @@ -113,4 +114,4 @@ - \ No newline at end of file + diff --git a/NOTICE.md b/NOTICE.md index d75fe99522..bedc11379d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1491,93 +1491,50 @@ 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 +- 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 diff --git a/PowerToys.sln b/PowerToys.sln index bedee18991..cedc7f05b4 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 @@ -796,6 +797,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 @@ -2898,6 +2903,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 @@ -3214,6 +3235,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/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/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/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/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 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/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/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/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 @@ + + + + + + + + + Settings - - Exit + + Close + Close as a verb, as in Close the application Direct 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/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 - + @@ -389,5 +369,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 69a7bec01f..e5759678c4 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. @@ -12,6 +12,7 @@ using Common.Search; using Common.Search.FuzzSearch; 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; @@ -120,7 +121,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. @@ -149,6 +150,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); @@ -491,17 +494,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); } private List _lastSearchResults = new(); 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 75e4a50aae..95f4834ea8 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 @@ - +