diff --git a/PowerToys.sln b/PowerToys.sln index 4e5524c757..4c9c92ce47 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -811,6 +811,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" EndProject +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 @@ -2945,6 +2947,14 @@ Global {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 + {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 @@ -3267,6 +3277,7 @@ Global {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.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/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