CmdPal/Clipboard History: Ctrl+O to open links (#42115)

Basically #42109, but with tests added, and no duplicated OpenUrl
command.
Closes #42108.

Tests pass. 

Tested with both copy as default and paste as default, and things show
up as expected.
This commit is contained in:
Mike Griese
2025-10-01 05:50:53 -05:00
committed by GitHub
parent fae466887c
commit 0b9b91c060
8 changed files with 498 additions and 17 deletions

View File

@@ -811,6 +811,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64 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|ARM64.Build.0 = Release|ARM64
{E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64
{E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -3267,6 +3277,7 @@ Global
{E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {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 EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<RootNamespace>Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}

View File

@@ -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
{
/// <summary>
/// Validates if a string is a valid URL or file path
/// </summary>
/// <param name="url">The string to validate</param>
/// <returns>True if the string is a valid URL or file path, false otherwise</returns>
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;
}
/// <summary>
/// Normalizes a URL or file path by adding appropriate schema if none is present
/// </summary>
/// <param name="url">The URL or file path to normalize</param>
/// <returns>Normalized URL or file path with schema</returns>
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;
}
/// <summary>
/// Checks if a string represents a valid file path (local or network)
/// </summary>
/// <param name="path">The string to check</param>
/// <returns>True if the string is a valid file path, false otherwise</returns>
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;
}
}
}

View File

@@ -2,11 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // 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;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System; using Windows.System;
@@ -16,4 +11,6 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory;
internal static class KeyChords internal static class KeyChords
{ {
internal static KeyChord DeleteEntry { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); 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);
} }

View File

@@ -22,6 +22,7 @@ internal sealed partial class ClipboardListItem : ListItem
private readonly CommandContextItem _deleteContextMenuItem; private readonly CommandContextItem _deleteContextMenuItem;
private readonly CommandContextItem? _pasteCommand; private readonly CommandContextItem? _pasteCommand;
private readonly CommandContextItem? _copyCommand; private readonly CommandContextItem? _copyCommand;
private readonly CommandContextItem? _openUrlCommand;
private readonly Lazy<Details> _lazyDetails; private readonly Lazy<Details> _lazyDetails;
public override IDetails? Details public override IDetails? Details
@@ -72,11 +73,26 @@ internal sealed partial class ClipboardListItem : ListItem
_pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager));
_copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); _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 else
{ {
_pasteCommand = null; _pasteCommand = null;
_copyCommand = null; _copyCommand = null;
_openUrlCommand = null;
} }
RefreshCommands(); RefreshCommands();
@@ -99,12 +115,7 @@ internal sealed partial class ClipboardListItem : ListItem
{ {
case PrimaryAction.Paste: case PrimaryAction.Paste:
Command = _pasteCommand?.Command; Command = _pasteCommand?.Command;
MoreCommands = MoreCommands = BuildMoreCommands(_copyCommand);
[
_copyCommand!,
new Separator(),
_deleteContextMenuItem,
];
if (_item.IsText) if (_item.IsText)
{ {
@@ -124,12 +135,7 @@ internal sealed partial class ClipboardListItem : ListItem
case PrimaryAction.Copy: case PrimaryAction.Copy:
default: default:
Command = _copyCommand?.Command; Command = _copyCommand?.Command;
MoreCommands = MoreCommands = BuildMoreCommands(_pasteCommand);
[
_pasteCommand!,
new Separator(),
_deleteContextMenuItem,
];
if (_item.IsText) if (_item.IsText)
{ {
@@ -148,6 +154,26 @@ internal sealed partial class ClipboardListItem : ListItem
} }
} }
private IContextItem[] BuildMoreCommands(CommandContextItem? firstCommand)
{
var commands = new List<IContextItem>();
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() private Details CreateDetails()
{ {
IDetailsElement[] metadata = IDetailsElement[] metadata =

View File

@@ -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")]

View File

@@ -183,4 +183,7 @@
<data name="settings_primary_action_copy" xml:space="preserve"> <data name="settings_primary_action_copy" xml:space="preserve">
<value>Copy to Clipboard</value> <value>Copy to Clipboard</value>
</data> </data>
<data name="open_url_command_name" xml:space="preserve">
<value>Open URL</value>
</data>
</root> </root>