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