diff --git a/.github/actions/spell-check/allow/names.txt b/.github/actions/spell-check/allow/names.txt index 475e68045b..f1216a159d 100644 --- a/.github/actions/spell-check/allow/names.txt +++ b/.github/actions/spell-check/allow/names.txt @@ -210,6 +210,7 @@ capturevideosample cmdow Controlz cortana +devhints dlnilsson fancymouse firefox @@ -229,6 +230,7 @@ regedit roslyn Skia Spotify +tldr Vanara wangyi WEX diff --git a/.pipelines/loc/loc.yml b/.pipelines/loc/loc.yml index cc4512c92e..2abc298652 100644 --- a/.pipelines/loc/loc.yml +++ b/.pipelines/loc/loc.yml @@ -29,8 +29,8 @@ steps: displayName: 'Touchdown Build - 37400, PRODEXT' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | src\**\Resources.resx src\**\Resource.resx diff --git a/.pipelines/v2/ci.yml b/.pipelines/v2/ci.yml index 297c268757..6b0105a38a 100644 --- a/.pipelines/v2/ci.yml +++ b/.pipelines/v2/ci.yml @@ -32,7 +32,7 @@ parameters: - name: enableMsBuildCaching type: boolean displayName: "Enable MSBuild Caching" - default: false + default: true - name: runTests type: boolean displayName: "Run Tests" diff --git a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml index 44f8c4b6dc..58f2fe6c47 100644 --- a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml +++ b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml @@ -8,8 +8,8 @@ steps: displayName: 'Download Localization Files -- PowerToys 37400' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | **\Resources.resx **\Resource.resx diff --git a/Directory.Packages.props b/Directory.Packages.props index eabda4151d..c77a8898fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -108,7 +108,7 @@ - + diff --git a/PowerToys.sln b/PowerToys.sln index cd2f4de28d..50063816ea 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -823,6 +823,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3DCCD936 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\modules\LightSwitch\Tests\LightSwitch.UITests\LightSwitch.UITests.csproj", "{F5333ED7-06D8-4AB3-953A-36D63F08CB6F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -2985,6 +2987,14 @@ Global {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.ActiveCfg = Release|x64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Build.0 = Release|x64 {F5333ED7-06D8-4AB3-953A-36D63F08CB6F}.Release|x64.Deploy.0 = Release|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|ARM64.Build.0 = Debug|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|x64.ActiveCfg = Debug|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Debug|x64.Build.0 = Debug|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.ActiveCfg = Release|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3312,6 +3322,7 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/doc/thirdPartyRunPlugins.md b/doc/thirdPartyRunPlugins.md index eccdc3530e..a15cb542a8 100644 --- a/doc/thirdPartyRunPlugins.md +++ b/doc/thirdPartyRunPlugins.md @@ -50,6 +50,7 @@ Contact the developers of a plugin directly for assistance with a specific plugi | [Hotkeys](https://github.com/ruslanlap/PowerToysRun-Hotkeys) | [ruslanlap](https://github.com/ruslanlap) | Create, manage, and trigger custom keyboard shortcuts directly from PowerToys Run. | | [RandomGen](https://github.com/ruslanlap/PowerToysRun-RandomGen) | [ruslanlap](https://github.com/ruslanlap) | 🎲 Generate random data instantly with a single keystroke. Perfect for developers, testers, designers, and anyone who needs quick access to random data. Features include secure passwords, PINs, names, business data, dates, numbers, GUIDs, color codes, and more. Especially useful for designers who need random color codes and placeholder content. | | [Open With Cursor](https://github.com/VictorNoxx/PowerToys-Run-Cursor/) | [VictorNoxx](https://github.com/VictorNoxx) | Open Visual Studio, VS Code recents with Cursor AI | +| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. | ## Extending software plugins diff --git a/src/CmdPalVersion.props b/src/CmdPalVersion.props index 2be9bc69d4..c3c5d7b608 100644 --- a/src/CmdPalVersion.props +++ b/src/CmdPalVersion.props @@ -2,7 +2,10 @@ $(XES_APPXMANIFESTVERSION) + + 0.0.1.0 + Local diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx index 0486f9b68b..560907942b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx @@ -1,5 +1,64 @@ + diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props index 75ffb75e31..4722a0974e 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props @@ -3,12 +3,13 @@ true - + + diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj index ae3035a498..7d83967705 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj @@ -40,10 +40,13 @@ - - - + + + + all + runtime; build; native; contentfiles; analyzers + true + + + false diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip index f026bbba8b..7bfe8ce57b 100644 Binary files a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip and b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip differ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs index 66744b4c99..24d2ef47a6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs @@ -2,9 +2,17 @@ // 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.UI.Xaml; + namespace Microsoft.CmdPal.UI.Helpers; internal static class BindTransformers { public static bool Negate(bool value) => !value; + + public static Visibility EmptyToCollapsed(string? input) + => string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility EmptyOrWhitespaceToCollapsed(string? input) + => string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index af0eff2181..597072241a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -41,6 +41,31 @@ FalseValue="Visible" TrueValue="Collapsed" /> + + + + + + - + @@ -76,20 +101,13 @@ + - @@ -98,10 +116,7 @@ - + + Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" /> - + - + @@ -36,18 +37,19 @@ + Loaded="NavView_Loaded"> 15,0,0,0 - - - - - - - - - - - - 28 - 7,4,8,0 - SemiBold - 16 - - - - + + + + + + + + + + 28 + 7,4,8,0 + SemiBold + 16 + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs index b3e1647294..5d042a09e3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs @@ -14,6 +14,7 @@ using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using WinUIEx; using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; +using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar; namespace Microsoft.CmdPal.UI.Settings; @@ -34,7 +35,7 @@ public sealed partial class SettingsWindow : WindowEx, var title = RS_.GetString("SettingsWindowTitle"); this.AppWindow.Title = title; this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; - this.TitleBar.Title = title; + this.AppTitleBar.Title = title; PositionCentered(); WeakReferenceMessenger.Default.Register(this); @@ -142,11 +143,13 @@ public sealed partial class SettingsWindow : WindowEx, { if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) { - NavView.IsPaneToggleButtonVisible = false; + AppTitleBar.IsPaneToggleButtonVisible = true; + WorkAroundIcon.Margin = new Thickness(8, 0, 16, 0); // Required for workaround, see XAML comment } else { - NavView.IsPaneToggleButtonVisible = true; + AppTitleBar.IsPaneToggleButtonVisible = false; + WorkAroundIcon.Margin = new Thickness(16, 0, 0, 0); // Required for workaround, see XAML comment } } @@ -155,6 +158,11 @@ public sealed partial class SettingsWindow : WindowEx, // This might come in on a background thread DispatcherQueue.TryEnqueue(() => Close()); } + + private void AppTitleBar_PaneToggleRequested(TitleBar sender, object args) + { + NavView.IsPaneOpen = !NavView.IsPaneOpen; + } } public readonly struct Crumb diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj new file mode 100644 index 0000000000..73abdbe772 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + + + false + Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests + true + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs new file mode 100644 index 0000000000..8635a5e3c5 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs @@ -0,0 +1,274 @@ +// 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 Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests; + +[TestClass] +public class UrlHelperTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("\t")] + [DataRow("\r\n")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("test\nurl")] + [DataRow("test\rurl")] + [DataRow("http://example.com\nmalicious")] + [DataRow("https://test.com\r\nheader")] + public void IsValidUrl_ReturnsFalse_WhenUrlContainsNewlines(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("com")] + [DataRow("org")] + [DataRow("localhost")] + [DataRow("test")] + [DataRow("http")] + [DataRow("https")] + public void IsValidUrl_ReturnsFalse_WhenUrlDoesNotContainDot(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + [DataRow("https://subdomain.example.co.uk")] + [DataRow("http://192.168.1.1")] + [DataRow("https://example.com:8080/path")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsWellFormedAbsolute(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("www.example.com")] + [DataRow("example.org")] + [DataRow("subdomain.test.net")] + [DataRow("github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123")] + [DataRow("192.168.1.1")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsValidWithoutProtocol(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("not a url")] + [DataRow("invalid..url")] + [DataRow("http://")] + [DataRow("https://")] + [DataRow("://example.com")] + [DataRow("ht tp://example.com")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsInvalid(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(" https://www.example.com ")] + [DataRow("\t\tgithub.com\t\t")] + [DataRow(" \r\n stackoverflow.com \r\n ")] + public void IsValidUrl_TrimsWhitespace_BeforeValidation(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("tel:+1234567890")] + [DataRow("javascript:alert('test')")] + public void IsValidUrl_ReturnsFalse_ForNonWebProtocols(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void NormalizeUrl_ReturnsInput_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + public void NormalizeUrl_ReturnsUnchanged_WhenUrlIsAlreadyWellFormed(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("www.example.com", "https://www.example.com")] + [DataRow("example.org", "https://example.org")] + [DataRow("github.com/user/repo", "https://github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123", "https://stackoverflow.com/questions/123")] + public void NormalizeUrl_AddsHttpsPrefix_WhenNoProtocolPresent(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(" www.example.com ", "https://www.example.com")] + [DataRow("\t\tgithub.com\t\t", "https://github.com")] + [DataRow(" \r\n stackoverflow.com \r\n ", "https://stackoverflow.com")] + public void NormalizeUrl_TrimsWhitespace_BeforeNormalizing(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject\readme.md")] + [DataRow(@"E:\")] + [DataRow(@"F:")] + [DataRow(@"G:\folder\subfolder")] + public void IsValidUrl_ReturnsTrue_ForValidLocalPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\server\share\folder")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents\file.docx")] + [DataRow(@"\\domain.com\share\folder\file.pdf")] + public void IsValidUrl_ReturnsTrue_ForValidNetworkPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\")] + [DataRow(@":")] + [DataRow(@"Z")] + [DataRow(@"folder")] + [DataRow(@"folder\file.txt")] + [DataRow(@"documents\project\readme.md")] + [DataRow(@"./config/settings.json")] + [DataRow(@"../data/input.csv")] + public void IsValidUrl_ReturnsFalse_ForInvalidPathsAndRelativePaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject")] + [DataRow(@"E:\")] + public void NormalizeUrl_ConvertsLocalPathToFileUri_WhenValidLocalPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file:///", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents")] + public void NormalizeUrl_ConvertsNetworkPathToFileUri_WhenValidNetworkPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file://", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow("file:///C:/Users/Test/file.txt")] + [DataRow("file://server/share/folder")] + public void NormalizeUrl_ReturnsUnchanged_WhenAlreadyFileUri(string fileUri) + { + // Act + var result = UrlHelper.NormalizeUrl(fileUri); + + // Assert + Assert.AreEqual(fileUri, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs index 67c98f274b..919790f198 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs @@ -23,6 +23,7 @@ public class NormalizeCommandLineTests : CommandPaletteUnitTestBase [DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")] [DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")] [DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")] + [DataRow("ipconfig a b \"c d\"", "c:\\Windows\\system32\\ipconfig.exe", "a b \"c d\"")] public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "") { NormalizeTestCore(input, expectedExe, expectedArgs); @@ -46,7 +47,7 @@ public class NormalizeCommandLineTests : CommandPaletteUnitTestBase [TestMethod] [DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")] [DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")] - [DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "--run --test --pass")] + [DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "\"--run --test\" --pass")] public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "") { NormalizeTestCore(input, expectedExe, expectedArgs); diff --git a/src/modules/cmdpal/custom.props b/src/modules/cmdpal/custom.props index cf04c2de38..86541e31cc 100644 --- a/src/modules/cmdpal/custom.props +++ b/src/modules/cmdpal/custom.props @@ -5,7 +5,7 @@ true 2025 0 - 5 + 6 Microsoft Command Palette diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs new file mode 100644 index 0000000000..60e7851761 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -0,0 +1,144 @@ +// 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.IO; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; + +internal static class UrlHelper +{ + /// + /// Validates if a string is a valid URL or file path + /// + /// The string to validate + /// True if the string is a valid URL or file path, false otherwise + internal static bool IsValidUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + // Trim whitespace for validation + url = url.Trim(); + + // URLs should not contain newlines + if (url.Contains('\n', StringComparison.Ordinal) || url.Contains('\r', StringComparison.Ordinal)) + { + return false; + } + + // Check if it's a valid file path (local or network) + if (IsValidFilePath(url)) + { + return true; + } + + if (!url.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + // eg: 'com', 'org'. We don't think it's a valid url. + // This can simplify the logic of checking if the url is valid. + return false; + } + + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + return true; + } + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.IsWellFormedUriString("https://" + url, UriKind.Absolute)) + { + return true; + } + } + + return false; + } + + /// + /// Normalizes a URL or file path by adding appropriate schema if none is present + /// + /// The URL or file path to normalize + /// Normalized URL or file path with schema + internal static string NormalizeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return url; + } + + // Trim whitespace + url = url.Trim(); + + // If it's a valid file path, convert to file:// URI + if (IsValidFilePath(url) && !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + try + { + // Convert to file URI (path is already absolute since we only accept absolute paths) + return new Uri(url).ToString(); + } + catch + { + // If conversion fails, return original + return url; + } + } + + if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + url = "https://" + url; + } + } + + return url; + } + + /// + /// Checks if a string represents a valid file path (local or network) + /// + /// The string to check + /// True if the string is a valid file path, false otherwise + private static bool IsValidFilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + // Check for UNC paths (network paths starting with \\) + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + // Basic UNC path validation: \\server\share or \\server\share\path + var parts = path.Substring(2).Split('\\', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2; // At minimum: server and share + } + + // Check for drive letters (C:\ or C:) + if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') + { + return true; + } + + return false; + } + catch + { + return false; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs index 5d59d0d1f2..e30969b56c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs @@ -2,11 +2,6 @@ // 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.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; @@ -16,4 +11,6 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory; internal static class KeyChords { internal static KeyChord DeleteEntry { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); + + internal static KeyChord OpenUrl { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.O); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs index ac19335bfa..9b5aae6f7d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -22,6 +22,7 @@ internal sealed partial class ClipboardListItem : ListItem private readonly CommandContextItem _deleteContextMenuItem; private readonly CommandContextItem? _pasteCommand; private readonly CommandContextItem? _copyCommand; + private readonly CommandContextItem? _openUrlCommand; private readonly Lazy
_lazyDetails; public override IDetails? Details @@ -72,11 +73,26 @@ internal sealed partial class ClipboardListItem : ListItem _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); + + // Check if the text content is a valid URL and add OpenUrl command + if (UrlHelper.IsValidUrl(_item.Content ?? string.Empty)) + { + var normalizedUrl = UrlHelper.NormalizeUrl(_item.Content ?? string.Empty); + _openUrlCommand = new CommandContextItem(new OpenUrlCommand(normalizedUrl)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + } + else + { + _openUrlCommand = null; + } } else { _pasteCommand = null; _copyCommand = null; + _openUrlCommand = null; } RefreshCommands(); @@ -99,12 +115,7 @@ internal sealed partial class ClipboardListItem : ListItem { case PrimaryAction.Paste: Command = _pasteCommand?.Command; - MoreCommands = - [ - _copyCommand!, - new Separator(), - _deleteContextMenuItem, - ]; + MoreCommands = BuildMoreCommands(_copyCommand); if (_item.IsText) { @@ -124,12 +135,7 @@ internal sealed partial class ClipboardListItem : ListItem case PrimaryAction.Copy: default: Command = _copyCommand?.Command; - MoreCommands = - [ - _pasteCommand!, - new Separator(), - _deleteContextMenuItem, - ]; + MoreCommands = BuildMoreCommands(_pasteCommand); if (_item.IsText) { @@ -148,6 +154,26 @@ internal sealed partial class ClipboardListItem : ListItem } } + private IContextItem[] BuildMoreCommands(CommandContextItem? firstCommand) + { + var commands = new List(); + + if (firstCommand != null) + { + commands.Add(firstCommand); + } + + if (_openUrlCommand != null) + { + commands.Add(_openUrlCommand); + } + + commands.Add(new Separator()); + commands.Add(_deleteContextMenuItem); + + return commands.ToArray(); + } + private Details CreateDetails() { IDetailsElement[] metadata = diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..fbc2b32860 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx index 70226f7292..0af6ee4cfc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx @@ -183,4 +183,7 @@ Copy to Clipboard + + Open URL + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index 621a265b28..14d605f458 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -157,7 +157,98 @@ public class ShellListPageHelpers executable = segments[0]; if (segments.Length > 1) { - arguments = string.Join(' ', segments[1..]); + arguments = ArgumentBuilder.BuildArguments(segments[1..]); + } + } + + private static class ArgumentBuilder + { + internal static string BuildArguments(string[] arguments) + { + if (arguments.Length <= 0) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + foreach (var argument in arguments) + { + AppendArgument(stringBuilder, argument); + } + + return stringBuilder.ToString(); + } + + private static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length > 0) + { + stringBuilder.Append(' '); + } + + if (argument.Length == 0 || ShouldBeQuoted(argument)) + { + stringBuilder.Append('\"'); + var index = 0; + while (index < argument.Length) + { + var c = argument[index++]; + if (c == '\\') + { + var numBackSlash = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + numBackSlash++; + } + + if (index == argument.Length) + { + stringBuilder.Append('\\', numBackSlash * 2); + } + else if (argument[index] == '\"') + { + stringBuilder.Append('\\', (numBackSlash * 2) + 1); + stringBuilder.Append('\"'); + index++; + } + else + { + stringBuilder.Append('\\', numBackSlash); + } + + continue; + } + + if (c == '\"') + { + stringBuilder.Append('\\'); + stringBuilder.Append('\"'); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append('\"'); + } + else + { + stringBuilder.Append(argument); + } + } + + private static bool ShouldBeQuoted(string s) + { + foreach (var c in s) + { + if (char.IsWhiteSpace(c) || c == '\"') + { + return true; + } + } + + return false; } } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs index 0ef003b4fe..e2fd310a60 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs @@ -159,15 +159,6 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties { } } - /// - /// Looks up a localized string similar to Open. - /// - internal static string Page_Name { - get { - return ResourceManager.GetString("Page_Name", resourceCulture); - } - } - /// /// Looks up a localized string similar to Settings. /// diff --git a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs deleted file mode 100644 index aabe2aff53..0000000000 --- a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal abstract class ActivationHandler - { - public abstract bool CanHandle(object args); - - public abstract Task HandleAsync(object args); - } - - [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "abstract T and abstract")] - internal abstract class ActivationHandler : ActivationHandler - where T : class - { - public override async Task HandleAsync(object args) - { - await HandleInternalAsync(args as T).ConfigureAwait(false); - } - - public override bool CanHandle(object args) - { - // CanHandle checks the args is of type you have configured - return args is T && CanHandleInternal(args as T); - } - - // Override this method to add the activation logic in your activation handler - protected abstract Task HandleInternalAsync(T args); - - // You can override this method to add extra validation on activation args - // to determine if your ActivationHandler should handle this activation args - protected virtual bool CanHandleInternal(T args) - { - return true; - } - } -} diff --git a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs deleted file mode 100644 index 946fab205c..0000000000 --- a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -// 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.Threading.Tasks; - -using Microsoft.PowerToys.Settings.UI.Services; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - internal sealed class DefaultActivationHandler : ActivationHandler - { - private readonly Type navElement; - - public DefaultActivationHandler(Type navElement) - { - this.navElement = navElement; - } - - protected override async Task HandleInternalAsync(IActivatedEventArgs args) - { - // When the navigation stack isn't restored, navigate to the first page and configure - // the new page by passing required information in the navigation parameter - object arguments = null; - if (args is LaunchActivatedEventArgs launchArgs) - { - arguments = launchArgs.Arguments; - } - - NavigationService.Navigate(navElement, arguments); - await Task.CompletedTask.ConfigureAwait(false); - } - - protected override bool CanHandleInternal(IActivatedEventArgs args) - { - // None of the ActivationHandlers has handled the app activation - return NavigationService.Frame.Content == null && navElement != null; - } - } -} diff --git a/src/settings-ui/Settings.UI/Services/ActivationService.cs b/src/settings-ui/Settings.UI/Services/ActivationService.cs deleted file mode 100644 index 86ad2e4d7c..0000000000 --- a/src/settings-ui/Settings.UI/Services/ActivationService.cs +++ /dev/null @@ -1,106 +0,0 @@ -// 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.Linq; -using System.Threading.Tasks; - -using Microsoft.PowerToys.Settings.UI.Activation; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Services -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal sealed class ActivationService - { - private readonly App app; - private readonly Type defaultNavItem; - private Lazy shell; - - private object lastActivationArgs; - - public ActivationService(App app, Type defaultNavItem, Lazy shell = null) - { - this.app = app; - this.shell = shell; - this.defaultNavItem = defaultNavItem; - } - - public async Task ActivateAsync(object activationArgs) - { - if (IsInteractive(activationArgs)) - { - // Initialize services that you need before app activation - // take into account that the splash screen is shown while this code runs. - await InitializeAsync().ConfigureAwait(false); - - // Do not repeat app initialization when the Window already has content, - // just ensure that the window is active - if (Window.Current.Content == null) - { - // Create a Shell or Frame to act as the navigation context - Window.Current.Content = shell?.Value ?? new Frame(); - } - } - - // Depending on activationArgs one of ActivationHandlers or DefaultActivationHandler - // will navigate to the first page - await HandleActivationAsync(activationArgs).ConfigureAwait(false); - lastActivationArgs = activationArgs; - - if (IsInteractive(activationArgs)) - { - // Ensure the current window is active - Window.Current.Activate(); - - // Tasks after activation - await StartupAsync().ConfigureAwait(false); - } - } - - private static async Task InitializeAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private async Task HandleActivationAsync(object activationArgs) - { - var activationHandler = GetActivationHandlers() - .FirstOrDefault(h => h.CanHandle(activationArgs)); - - if (activationHandler != null) - { - await activationHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - - if (IsInteractive(activationArgs)) - { - var defaultHandler = new DefaultActivationHandler(defaultNavItem); - if (defaultHandler.CanHandle(activationArgs)) - { - await defaultHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - } - } - - private static async Task StartupAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private static IEnumerable GetActivationHandlers() - { - yield break; - } - - private static bool IsInteractive(object args) - { - return args is IActivatedEventArgs; - } - } -} diff --git a/src/settings-ui/Settings.UI/Services/NavigationService.cs b/src/settings-ui/Settings.UI/Services/NavigationService.cs index b70976bd01..d7c408208b 100644 --- a/src/settings-ui/Settings.UI/Services/NavigationService.cs +++ b/src/settings-ui/Settings.UI/Services/NavigationService.cs @@ -24,12 +24,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services { get { - if (frame == null) - { - frame = Window.Current.Content as Frame; - RegisterFrameEvents(); - } - return frame; }